From a56e84f13ce9954f979ffb78cf8690cd3f6538a2 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 12:08:06 +0100 Subject: [PATCH 01/19] chore: prepare synpath 0.9.0 release Betfair Exchange and Smarkets (market data and streams), market data from an API key alone for Opinion, predict.fun and SX Bet, and historical replay in venue time, since 0.8.1. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- synpath/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc3c13a..033d26c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ backend-path = ["build_backend"] [project] name = "synpath" -version = "0.8.1" +version = "0.9.0" description = "One API for 8 prediction markets: Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet. Market data, streaming, self-hosted trading and smart order routing." readme = "README.md" requires-python = ">=3.10" diff --git a/synpath/__init__.py b/synpath/__init__.py index cbd0392..b43731a 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -82,7 +82,7 @@ TradesRangeResponse, ) -__version__ = "0.8.1" +__version__ = "0.9.0" exchanges: dict[str, type[Exchange]] = { "kalshi": Kalshi, From 17b45bbbe5a8ba85138701f949bae8fba8365d82 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 17:59:54 +0100 Subject: [PATCH 02/19] feat: add gemini Co-Authored-By: Claude Opus 5.5 --- .env.example | 12 + README.md | 9 +- docs/api.md | 25 +- docs/capabilities.md | 123 ++- docs/streaming.md | 23 + docs/trading.md | 59 +- synpath/__init__.py | 10 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 9 + synpath/gemini.py | 874 ++++++++++++++++++++ synpath/ids.py | 5 +- synpath/server/serve.py | 2 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 7 + synpath/trading/credentials.py | 52 +- synpath/trading/gemini.py | 807 +++++++++++++++++++ synpath/trading/init.py | 17 + synpath/trading/instruments.py | 6 + synpath/types.py | 23 +- synpath/ws/__init__.py | 5 +- synpath/ws/gemini.py | 540 +++++++++++++ tests/samples/gemini_book.json | 31 + tests/samples/gemini_candles.json | 50 ++ tests/samples/gemini_categories.json | 18 + tests/samples/gemini_crypto_event.json | 108 +++ tests/samples/gemini_event.json | 649 +++++++++++++++ tests/samples/gemini_events.json | 1014 ++++++++++++++++++++++++ tests/samples/gemini_not_found.json | 1 + tests/samples/gemini_trades.json | 56 ++ tests/samples/gemini_trading.json | 347 ++++++++ tests/samples/gemini_ws.json | 197 +++++ tests/test_gemini.py | 335 ++++++++ tests/test_gemini_live.py | 60 ++ tests/test_gemini_trading.py | 414 ++++++++++ tests/test_gemini_ws.py | 234 ++++++ tests/test_init.py | 47 +- 37 files changed, 6111 insertions(+), 70 deletions(-) create mode 100644 synpath/gemini.py create mode 100644 synpath/trading/gemini.py create mode 100644 synpath/ws/gemini.py create mode 100644 tests/samples/gemini_book.json create mode 100644 tests/samples/gemini_candles.json create mode 100644 tests/samples/gemini_categories.json create mode 100644 tests/samples/gemini_crypto_event.json create mode 100644 tests/samples/gemini_event.json create mode 100644 tests/samples/gemini_events.json create mode 100644 tests/samples/gemini_not_found.json create mode 100644 tests/samples/gemini_trades.json create mode 100644 tests/samples/gemini_trading.json create mode 100644 tests/samples/gemini_ws.json create mode 100644 tests/test_gemini.py create mode 100644 tests/test_gemini_live.py create mode 100644 tests/test_gemini_trading.py create mode 100644 tests/test_gemini_ws.py diff --git a/.env.example b/.env.example index 0934e99..b3d6a93 100644 --- a/.env.example +++ b/.env.example @@ -114,3 +114,15 @@ BETFAIR_JURISDICTION= # prohibited. SMARKETS_USERNAME= SMARKETS_PASSWORD= + +# Gemini Predictions (Gemini Titan): market data and the market stream need +# nothing. To trade, an API key from exchange.gemini.com/settings/api on a +# Gemini account with Prediction Markets access (US) that has accepted the +# current Prediction Markets terms: account-scoped, Trader role, with "Uses a +# time-based nonce" ticked (the WebSocket takes no other kind). The secret is +# shown once. GEMINI_ACCOUNT names the subaccount a master key acts for (REST +# only). GEMINI_ENV=sandbox uses api.sandbox.gemini.com with a sandbox key. +GEMINI_API_KEY= +GEMINI_API_SECRET= +GEMINI_ACCOUNT= +GEMINI_ENV=prod diff --git a/README.md b/README.md index 649c0d2..a5af74e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Synpath – One API for prediction markets Follow @Synpath_Dev on X **Build on prediction markets without building against each one.**
-One open-source API across 8 venues (Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet): market data, order entry, live streams, and an execution engine that remembers what it sent. In-process from Python, over REST and WebSocket from anywhere. +One open-source API across 9 venues (Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Gemini): market data, order entry, live streams, and an execution engine that remembers what it sent. In-process from Python, over REST and WebSocket from anywhere. License: MIT Total Downloads @@ -80,16 +80,19 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [predict.fun](https://predict.fun) | `predict_fun` | ✓ (API key) | ✓ | WebSocket (API key) | one book per market, both sides read it | | | [Limitless](https://limitless.exchange) | `limitless` | ✓ | ✓ | WebSocket | one book per market, both sides read it | | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | +| | [Gemini Predictions](https://www.gemini.com/predictions) | `gemini` | ✓ no account, sports taxonomy | ✓ US accounts with Prediction Markets access | WebSocket (no key for books) | one book per contract, in YES space | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). +Gemini Predictions is Gemini Titan's CFTC-registered event-contract exchange. Each contract is a yes/no market named by its instrument symbol, `gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`, and the Gemini event holding the contracts is the event. Sports events carry Gemini's taxonomy (moneyline, spread, total, prop; the sport, scope and statistic), which listings filter on. Market data needs no account; trading needs a Gemini account with Prediction Markets access (US) and an API key. See [capabilities](docs/capabilities.md#gemini). + Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). ## Why Synpath -- **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. +- **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Gemini, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. - **Traders deserve advanced order types.** Stops, trailing stops, icebergs, OCO, brackets, TWAP and pegs, on every venue, even where the exchange has none. Orders are journaled before they're sent, so a crash never places one twice. @@ -135,7 +138,7 @@ fee.estimate(price=0.50, contracts=100) # 1.75 **Same code, every venue** ```python -for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets'] (Betfair and predict.fun need keys) +for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets', 'gemini'] (Betfair and predict.fun need keys) with synpath.exchange(venue_id) as venue: page = venue.fetch_markets(limit=5) diff --git a/docs/api.md b/docs/api.md index dfbf90e..1661eb0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,7 +77,7 @@ and spot exchanges do not. | Method | Returns | Notes | |---|---|---| | `fetch_markets(query, limit, cursor, status, sort)` | `Page[Market]` | `limit` up to 100 | -| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read), order preserved, closed and settled markets included | +| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read; Gemini one event read per event), order preserved, closed and settled markets included | | `fetch_events(query, limit, cursor, status)` | `Page[Event]` | markets nested | | `fetch_market(market_id)` | `Market` | | | `fetch_order_book(market_id, side, depth)` | `OrderBook` | `side="yes"` (default) or `"no"` | @@ -122,6 +122,22 @@ book.info["bids"], book.info["offers"] # Smarkets' own [price bps, quantity] smk.fetch_trades(contracts[0].id) # the short recent tape ``` +Gemini needs no account for market data. It adds `fetch_event(id)` (one event +with its contracts, any status), `fetch_gemini_event(ticker)` (the raw event, +its child events included), `fetch_categories(status)` and `fetch_strike(id)` +(a crypto Up/Down event's strike once set), and takes `category` and the sports +taxonomy, `sport`, `sports_market_type`, `sports_market_subject`, +`sports_market_scope` and `sports_market_metric` (each a value or a list), on +`fetch_markets` and `fetch_events`. See [Gemini](capabilities.md#gemini). + +```python +gemini = synpath.Gemini() # no account +lines = gemini.fetch_markets(sport="basketball", sports_market_type="spread", limit=9) +lines[0].info["sports_market"] # {"sport": "basketball", "type": "spread", "subject": "team", ...} +lines[0].info["strike"] # {"type": "spread", "value": "1.5"} +book = gemini.fetch_order_book(lines[0].id) # YES space; side="no" mirrors it +``` + Polymarket also has `fetch_order_books_by_token(token_ids, depth)`, by CLOB token id. @@ -204,7 +220,7 @@ result.matched # MarketLink | None Matching covers every venue this library speaks: `kalshi`, `polymarket`, `polymarket_us`, `opinion`, `hyperliquid`, `predict_fun`, `limitless` and -`sx_bet` (not Betfair or Smarkets yet). `venue=` asks one of them in particular; without it the answer is +`sx_bet` (not Betfair, Smarkets or Gemini yet). `venue=` asks one of them in particular; without it the answer is the first venue listing the same question, in the order Polymarket, Kalshi, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Polymarket US. @@ -256,13 +272,14 @@ A venue that cannot filter by one of them raises `NotSupported`. Polymarket does for `settled`, since its catalog does not publish resolution status, and Betfair for everything but `open`, since a closed market leaves its catalogue (read it by id instead). Smarkets filters on its events' states: `closed` is an -ended or suspended event, `settled` a settled or cancelled one. +ended or suspended event, `settled` a settled or cancelled one. Gemini's `closed` +is a closed or under-review event, `settled` a settled or invalid one. Anything outside the four raises `BadRequest` rather than being handed to the venue to interpret for itself. ### Identifiers -One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). +One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. On Gemini a market is a contract named by its instrument symbol, `gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`, and its event the Gemini event, `gemini:NBA-2610232000-CHI-MIL-M`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). ### Paging diff --git a/docs/capabilities.md b/docs/capabilities.md index b521b2a..fdaf87f 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -6,21 +6,21 @@ Read `has` before you call. A capability a venue lacks raises `NotSupported` rather than returning an empty list, so "cannot" is never mistaken for "there is nothing". -| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | -|---|---|---|---|---|---|---|---|---|---|---| -| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | -| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | -| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | -| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | -| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | -| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | -| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | -| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | -| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | -| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | -| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | -| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | -| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | `gemini` | +|---|---|---|---|---|---|---|---|---|---|---| --- | +| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | yes, each contract a market, events in the venue's order; filter by category and the sports taxonomy (`sport`, `sports_market_type`, subject, scope, metric), no account | +| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | yes, one per Gemini event (its own contracts); a game's spreads, totals and props are their own events, named in `info["child_events"]` | +| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | yes, any status; a symbol not seen in a listing costs one event read per candidate ticker | +| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | yes, the whole book in YES space; NO mirrored | +| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | yes, one request per market | +| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | yes, seven days of public trades, 500 a request, paged forward by trade id | +| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | partial: the venue's candles over a fixed recent window per timeframe; a bar without trades repeats the last close (`sampled_last`) | +| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | +| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | yes, from the contract's listing date: `rate * C * P * (1 - P)` (0.07 taker, 0.0175 maker, 0 during the maker promotion to 31 December 2026), or the legacy `min(P, 1 - P)` schedule; up to the cent | +| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | yes, the venue's title search | +| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | yes, one event read per event | +| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | no: the listing takes no sort | +| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | no | Polymarket US is the CFTC-regulated exchange, not the on-chain CLOB the `polymarket` adapter reads; the two share a brand and nothing else. It has @@ -182,6 +182,69 @@ betting on your own account; odds scraping is prohibited. Which countries Smarkets refuses API use from is not published with its API documentation and is unverified here. +### Gemini + +Gemini Predictions is the event-contract exchange of Gemini Titan, a +CFTC-registered exchange. An *event* ("Chicago vs Milwaukee", +`gemini:NBA-2610232000-CHI-MIL-M`) holds *contracts*, one yes/no question +each; a contract is a Synpath market named by its instrument symbol, +`gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`, the symbol Gemini's books, +streams and orders use. Gemini says not to build a symbol from its parts, and +nothing here does: a symbol not met in a listing is looked up by asking the +events it could belong to, most likely first, and the event that lists it is +the answer. + +Every book is in YES space: a NO buyer at 0.30 is a YES ask at 0.70, so the +NO view is the YES book mirrored (`derived=True`). The event payload carries +each contract's best bid and ask, which become the quotes; it gives the last +trade price without a time, so that stays in `info["last_trade_price"]` +rather than the quote. Each contract publishes its own grid +(`info["price_increment"]`, `quantity_increment`, `quantity_minimum`; a cent +and a hundredth of a contract on every one read so far). Volume is per event, +in USD (`info["volume_usd"]`, `volume_24h_usd`). + +**Sports.** A sports event carries Gemini's taxonomy in `sportsMarket`, on +every market as `info["sports_market"]`: the sport, the family (`moneyline`, +`spread`, `total`, `prop`, `correct_score`, `to_advance`, `futures`), the +subject (`contest`, `team`, `player`, `participant`), the scope +(`full_contest`, `regulation`, `half` 1, innings 1 to 5) and the statistic +(`points`, `rushing_yards`). Listings filter on each (`sport=`, +`sports_market_type=`, `sports_market_subject=`, `sports_market_scope=`, +`sports_market_metric=`; a list matches any). A game's moneyline event names +its spread, total and prop events in `info["child_events"]`; the venue lists +those on their own as well, and a listing returns each once. Contracts carry +their strike (`info["strike"]`: a spread margin, a total line, or a crypto +Up/Down reference price once set). A moneyline or correct-score event is +mutually exclusive; nothing else is claimed. + +An event is `approved` (listed, not yet trading: `unopened`), `active`, +`closed`, `under_review` (awaiting its result: `closed`), `settled` or +`invalid` (voided: `settled`). An active contract whose `marketState` is +`closed` is `closed` once its expiry has passed and `open` with `active=False` +before. Listings take `status`: open is active, closed is closed or under +review, settled is settled or invalid. + +**Fees** depend on when a contract was listed. From 18 March 2026: `rate * C * +P * (1 - P)`, taker 0.07 and maker 0.0175; before: `rate * C * min(P, 1 - P)`, +taker 0.05 and maker 0.01. Fees round up to the cent. Gemini waives maker fees +from 2 September to 31 December 2026, and the schedule says so. There are no +settlement fees. + +**No account for market data.** Events, books, trades and candles are public +GETs (120 a minute, kept to two a second here). Trades come from Gemini's +seven-day public window; candles from a fixed recent window per timeframe. +`Gemini(sandbox=True)` reads api.sandbox.gemini.com. + +**Trading** needs a Gemini account with Prediction Markets access (US) and an +API key: account-scoped, with the Trader role and a time-based nonce (the +WebSocket takes no other kind). The account must have accepted Gemini's +current Prediction Markets terms; `GeminiTrading.fetch_terms_status()` says +whether it has. See [trading](trading.md#gemini). + +Combo RFQ is not offered. It is a sealed one-second auction for market makers +over the WebSocket, in beta, and does not map onto the request-and-accept RFQ +the Kalshi adapter has. + Matching spans venues, so no adapter claims it. `synpath.match_market(id)` and `synpath.match_event(id)` are answered by Synpath's hosted matching service at `api.synpath.dev`, with a Synpath API key (the one `synpath keys create` saved, `SYNPATH_API_KEY`, or `api_key=`). See @@ -209,20 +272,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | -|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | -| `rfq` | yes | no | no | no | no | no | no | no | no | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `gemini` | +|---|---|---|---|---|---|---|---|---|---| --- | +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; an order by id is searched among the active orders and the latest thousand of the history | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, filled or cancelled, by offset or a time window | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, netted on YES with both inventories kept; USD available and total | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | yes | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the contract's schedule, up to the cent | +| `rfq` | yes | no | no | no | no | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -258,6 +321,8 @@ Every stream answers the `watch_*` keys the same way | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | +| `GeminiMarketStream` | yes, differential, from a snapshot; a skipped update resubscribes | yes, top of book and last trade | yes | yes, contract status and strikes | no | no | no | no | +| `GeminiUserStream` | no | no | no | no | yes | yes, each execution with its fee | yes, settlement payouts included | yes | The exchange API's other gRPC streams carry one thing each and say so: drop copy and trade capture carry fills, position and position-change carry diff --git a/docs/streaming.md b/docs/streaming.md index 7a577f6..5e2f92d 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -43,6 +43,8 @@ asyncio.run(main()) | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | +| `GeminiMarketStream` | `ws.gemini.com` (or the sandbox) | none | differential books in YES space from a snapshot, top of book with the last trade, public prints, contract status and strikes | +| `GeminiUserStream` | `ws.gemini.com` | an account-scoped Gemini API key with a time-based nonce (the handshake is signed) | orders; each execution with its fee and maker flag; positions, settlement payouts included; balances | Each stream's `has` answers the `watch_*` capability keys (`watch_order_book`, `watch_ticker`, `watch_trades`, `watch_market_status`, `watch_orders`, @@ -69,6 +71,7 @@ variable `synpath serve` reads it from: | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | | Betfair | the application key and a login (every Betfair call needs one) | `BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD` | | Smarkets | nothing; an optional login undelays the quotes | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | +| Gemini | nothing | | `synpath serve` builds a market stream for Opinion, predict.fun and SX Bet from the API key alone (`load_market_data`), so a parent on any venue can @@ -204,6 +207,26 @@ jittered exponential backoff between attempts. says so in `info["delayed"]`. A change shows at the next poll, not as it happens, so its `watch_*` keys are `partial`. No `watch_trades`: the recent tape is short and unpaged; read it with `Smarkets.fetch_trades`. +- *Gemini* takes `{"id", "method": "SUBSCRIBE", "params": [stream names]}` + and answers each with its `id` and a status. The market stream connects + with `snapshot=-1` and subscribes `{symbol}@depth@100ms`, so the first frame + for a book is the whole book and the rest are the levels that changed (a + size of zero removes one). Frames chain by update id: each frame's first id + `U` is the last id `u` of the frame before, ids being the exchange's own + across all books. A frame whose `U` is past the last applied `u` is a gap: + the book is dropped, the stream unsubscribes and subscribes again, and the + fresh snapshot is reported `resynced`. Books are in YES space; `book(id, + "no")` mirrors one. `@bookTicker` is the top of book (and the last trade + price once there is one), `@trade` the tape (`m` says the buyer was the + maker, so the taker sold), `contractStatus` every contract's status changes. + Gemini sends symbols lower case; events carry them upper case, as REST + does. The account stream signs its handshake (`X-GEMINI-APIKEY`, `-NONCE`, + `-PAYLOAD`, `-SIGNATURE`) with an account-scoped key that uses a time-based + nonce; master keys are refused. An order frame carries only what changed, + so the stream keeps each order's last known fields; a `FILLED` or + `PARTIALLY_FILLED` frame is also a `FillEvent` (`Z` the execution's size, + `L` its price, `n` its fee). The first `positions@account` frame is the open + positions, then changes; a settlement closes a position with its payout. **A book is right or marked not ready.** After a gap or a disconnect the book's `ready` is false until a snapshot arrives; deltas are not applied to diff --git a/docs/trading.md b/docs/trading.md index 27a5edb..c4e3bcd 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -25,7 +25,7 @@ pip install synpath Order entry ships in the base package. The install carries the venues' signing stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, -Ed25519 and private-key JWT for Polymarket US. Nothing further is needed to place an order. +Ed25519 and private-key JWT for Polymarket US, HMAC-SHA384 for Gemini. Nothing further is needed to place an order. Polymarket and Opinion orders are hashed and signed by the Rust core (`synpath._core`), in about 20 microseconds rather than the 3 milliseconds @@ -71,6 +71,7 @@ private key. `synpath init` tells them apart. | predict.fun | mainnet: the API key (`pred_sk_...`). Test network: nothing | the API key and a private key: a plain wallet's, or a Predict account's owner key with its deposit address | the key at developers.predict.fun; a web-app account's owner key under Account -> Settings -> Privy wallet | | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | +| Gemini | nothing | an API key and its secret: account-scoped, Trader role, time-based nonce; a Gemini account with Prediction Markets access (US) that has accepted the current Prediction Markets terms | exchange.gemini.com/settings/api -> Create API key; tick "Uses a time-based nonce"; the secret is shown once | For Opinion, predict.fun and SX Bet the API key alone is a complete market-data setup: `synpath init` asks for it first and takes an empty @@ -95,6 +96,7 @@ The variables each venue reads: | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | +| Gemini | `GEMINI_API_KEY` (`account-...`), `GEMINI_API_SECRET`, `GEMINI_ACCOUNT` (optional; the subaccount a `master-...` key acts for, REST only: the WebSocket refuses master keys), `GEMINI_ENV` = `prod` (the default, real money) or `sandbox` (api.sandbox.gemini.com, with a sandbox key) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, optional `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` and `BETFAIR_JURISDICTION`) @@ -573,3 +575,58 @@ each market on the YES leg. The balance is the proxy wallet's USDC: what is free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. + +## Gemini + +`GeminiTrading`, on Gemini's private REST API for Prediction Markets. It needs +a Gemini account with Prediction Markets access, which Gemini offers in the +US, and an API key from exchange.gemini.com/settings/api: account-scoped, +with the Trader role, and a time-based nonce, which the account stream +requires. The account must have accepted Gemini's current Prediction Markets +terms: `fetch_terms_status()` says whether it has, `fetch_terms()` reads +them, and `accept_terms()` accepts them for the account group. Nothing here +accepts them on its own; an order sent before is refused with +`PermissionDenied`. + +Every private request sends an empty body. The JSON payload (the request path, +a nonce, and the endpoint's fields) goes base64-encoded in `X-GEMINI-PAYLOAD`, +signed with HMAC-SHA384 under the API secret into `X-GEMINI-SIGNATURE`. The +nonce is Unix seconds, which a time-based key accepts within 30 seconds of +Gemini's clock. Signed requests go one at a time, as Gemini's own SDK sends +them, under the venue's 600 requests a minute. A rate-limited read is signed +again and retried; a write is sent once. + +An order names its outcome. `buy` at *p* is sent as buying YES at *p*; +`sell` at *p* as buying NO at `1 - p`, Gemini's way to hold NO. With +`reduce_only`, the order sells what is held instead: `sell` sells YES at *p*, +`buy` sells NO at `1 - p`. Orders, fills and positions read back on the YES +leg, with the venue's side and outcome in `info`. Positions net on YES: +`inventory_yes` and `inventory_no` keep what each outcome holds, and +`entry_price` is on the YES leg. + +The venue takes limit orders. `market` and `ioc` are sent as +`immediate-or-cancel` at the price given as the worst accepted, `fok` as +`fill-or-kill`; post-only is `makerOrCancel`. There is no good-till-date, no +amend and no client order id on the wire: `client_order_id` stays on the +returned `Order`. Prices and quantities are checked against the cent tick +and the hundredth-contract grid unless a `Precision` from the contract's own +grid is passed. `create_orders` and `cancel_orders` send twenty a request; +Gemini checks a whole batch before sending any of it. + +There is no single-order route, so `fetch_order` searches the active orders +and the latest thousand of the history. `fetch_orders` reads the history +(`filled` or `cancelled`) by offset, or a time window with `since`. There is +no fills route either: `fetch_my_trades` is one aggregate fill per order at +its average price (`info["aggregate"]`), and `GeminiUserStream` carries each +execution with its fee and whether it made or took ([Streaming](streaming.md)). +`fetch_settlements` reads settled positions with their payouts. +`fetch_balance` is the account's USD: `available` to trade, `total` +confirmed. + +Fees follow the contract's listing date and are rounded up to the cent; see +[Gemini](capabilities.md#gemini). `fetch_fee_estimate` reads the contract's +schedule with one public request. + +Combo RFQ (Gemini's sealed WebSocket auction for multi-leg combos, in beta +and run by market makers) is not offered, and nor are WebSocket order +placement and `cancelOnDisconnect`. diff --git a/synpath/__init__.py b/synpath/__init__.py index b43731a..ea0d9bb 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -49,6 +49,7 @@ from .sx_bet import SxBet from .betfair import Betfair from .smarkets import Smarkets +from .gemini import Gemini from .matching import match_event, match_market from .history import fetch_order_book_at, fetch_order_book_range, fetch_trades_range from .bucket import Bucket, BucketMember, BucketOrderReport, BucketPosition @@ -95,6 +96,7 @@ "sx_bet": SxBet, "betfair": Betfair, "smarkets": Smarkets, + "gemini": Gemini, } """Every venue this library speaks, by id.""" @@ -115,7 +117,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, GeminiTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -145,7 +147,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "GeminiMarketStream", "GeminiUserStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -167,7 +169,7 @@ def __getattr__(name: str): __all__ = [ - "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Exchange", "exchange", "exchanges", + "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Gemini", "Exchange", "exchange", "exchanges", "Capability", "HttpClient", "RateLimiter", "Market", "Event", "Outcome", "Quote", "MarketStats", "OrderBook", "OrderLevel", "Trade", "Candle", "FeeSchedule", "Series", "Page", @@ -184,7 +186,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "GeminiTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index 20f063a..9fac740 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -383,7 +383,13 @@ def _sx_bet(creds: Credentials) -> TradingExchange: return SxBetTrading(creds) # type: ignore[arg-type] +def _gemini(creds: Credentials) -> TradingExchange: + from .trading.gemini import GeminiTrading + return GeminiTrading(creds) # type: ignore[arg-type] + + _TRADING = { "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, + "gemini": _gemini, } diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..207db56 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -65,6 +65,7 @@ "predict_fun": ("..trading.predict_fun", "PredictFunTrading"), "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), + "gemini": ("..trading.gemini", "GeminiTrading"), } diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..f41548b 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -158,6 +158,15 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] # Every Betfair stream, books included, logs in with the account. from ..ws.betfair import BetfairMarketStream out[venue] = VenueStreams(market=BetfairMarketStream(credentials=creds), private=None) + elif venue == "gemini": + # Books are public; the account stream signs its handshake with + # the API key, which must be account-scoped and time-based. + from ..ws.gemini import GeminiMarketStream, GeminiUserStream + sandbox = getattr(creds, "env", "prod") == "sandbox" + out[venue] = VenueStreams( + market=GeminiMarketStream(sandbox=sandbox), + private=GeminiUserStream(creds) if creds is not None else None, + ) elif venue == "smarkets": # Market data only, and public: a polled stream on REST quotes # (Smarkets' socket stream is for approved market makers). The diff --git a/synpath/gemini.py b/synpath/gemini.py new file mode 100644 index 0000000..af00438 --- /dev/null +++ b/synpath/gemini.py @@ -0,0 +1,874 @@ +"""Gemini Predictions (Gemini Titan, a CFTC-registered exchange), read from its +public HTTP API. + +**No account is needed for market data.** Events, contracts, books, trades +and candles all answer an anonymous GET on `api.gemini.com`. Trading needs a +Gemini account with Prediction Markets access (US) and an API key; see +`synpath.trading.gemini`. + +Four things about this venue that shape the adapter: + +**An event holds contracts, and each contract is a Synpath market.** "Chicago +vs Milwaukee" is an event (`gemini:NBA-2610232000-CHI-MIL-M`) with one +contract per team, each a yes/no question traded as its own instrument. The +market id is the contract's `instrumentSymbol`, the exchange's own symbol +for books, streams and orders: `gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`. +Gemini says not to build a symbol from its parts, so this adapter never does; +it reads them off the event. + +**One book per contract, in YES space.** Gemini normalizes every book to the +YES side: a NO buyer at 0.30 is a YES ask at 0.70. The NO view is that book +mirrored (`derived=True`). Orders name their outcome (`yes`, `no`) separately +from their side, which the trading adapter maps onto the YES leg. + +**Sports carry a taxonomy.** A sports event has `sportsMarket`: the sport, +the market family (`moneyline`, `spread`, `total`, `prop`, `correct_score`, +`to_advance`, `futures`), what it is about (`contest`, `team`, `player`), +the scope (`full_contest`, `half` 1, innings 1-5) and the statistic. It rides +on every market's `info["sports_market"]`, and listings filter on it +(`sport=`, `sports_market_type=`). A game event lists its spreads, totals and +props as child events, which the venue also lists on their own; a listing +returns each once, and the children's tickers are on the parent's +`info["child_events"]`. + +**Fees follow the contract's listing date.** Contracts listed from 18 March +2026 pay `rate * C * P * (1 - P)` (taker 0.07, maker 0.0175), rounded up to +the cent; older ones `rate * C * min(P, 1 - P)` (taker 0.05, maker 0.01). +Gemini waives maker fees from 2 September to 31 December 2026. See +`fee_schedule_of`. + +The public endpoints allow 120 requests a minute; the limiter here keeps to +two a second, shared process-wide. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +from collections import OrderedDict +from datetime import datetime, timezone +from typing import Any, Iterator + +from . import ids +from .base import ( + MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort, check_status, page_limit, + pick_bars, +) +from .errors import BadRequest, MarketNotFound +from .types import ( + BookSide, Candle, Event, FeeSchedule, Market, MarketStats, OrderBook, OrderLevel, Outcome, Page, Quote, Trade, + iso, +) + +VENUE = "gemini" +API_URL = "https://api.gemini.com" +SANDBOX_URL = "https://api.sandbox.gemini.com" + +SYMBOL_PREFIX = "GEMI-" +"""Every prediction-market instrument symbol starts with this.""" + +LIMITER = RateLimiter(2.0, burst=5) +"""Gemini allows 120 public requests a minute (and recommends one a second), +with a burst of five queued. Two a second, shared by every client in the +process, stays inside it.""" + +EVENT_PAGE = 50 +"""Events read per listing request while filling a page of markets.""" + +MAX_PAGES = 10 +"""Most event pages one listing call reads while filling a page.""" + +TRADES_PAGE = 500 +"""The most trades `/v1/trades` returns per request.""" + +SYMBOL_CACHE = 4096 +"""Instrument symbols remembered with their event ticker. Bounded, because the +server keeps one adapter for the life of the process.""" + +STATUSES: dict[str, tuple[str, ...] | None] = { + "open": ("active",), + "closed": ("closed", "under_review"), + "settled": ("settled", "invalid"), + "all": None, +} +"""Gemini's event statuses for each status a listing asks for. `approved` +events are listed but not yet trading (`unopened`); `all` includes them.""" + +CANDLE_FRAMES = { + "1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m", "1h": "1hr", "4h": "4hr", "6h": "6hr", "1d": "1day", +} +"""This library's timeframes and the `time_frame` each is on `/v2/candles`.""" + +TAKER_RATE = 0.07 +MAKER_RATE = 0.0175 +LEGACY_TAKER_RATE = 0.05 +LEGACY_MAKER_RATE = 0.01 +SCHEDULE_FROM = 1773792000000 +"""2026-03-18T00:00:00Z: contracts listed from this day pay the P * (1 - P) +schedule; earlier ones the legacy min(P, 1 - P) schedule.""" +MAKER_PROMO = (1788307200000, 1798761600000) +"""[2026-09-02, 2027-01-01) UTC: Gemini's maker fee-free promotion.""" +FEE_URL = "https://www.gemini.com/fees/predictions" + + +# --------------------------------------------------------------------------- +# Pure normalizers +# --------------------------------------------------------------------------- + +def to_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def parse_ts(value: Any) -> int | None: + """Milliseconds from Gemini's ISO 8601 strings (`2026-10-23T21:00:00.000Z`).""" + if value in (None, ""): + return None + if isinstance(value, (int, float)): + return int(value) + text = str(value).replace("Z", "+00:00") + head, dot, rest = text.partition(".") + if dot: + digits = rest[:len(rest) - len(rest.lstrip("0123456789"))] + zone = rest[len(digits):] + # Python 3.10's fromisoformat takes exactly 3 or 6 fraction digits. + text = f"{head}.{digits[:6].ljust(6, '0')}{zone}" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp() * 1000) + + +def quoted(value: Any) -> float | None: + """A price strictly inside (0, 1), or `None`.""" + price = to_float(value) + if price is None or price <= 0 or price >= 1: + return None + return price + + +def plain_text(value: Any) -> str | None: + """A contract's rich-text description (a document tree of nodes) as text.""" + if value in (None, ""): + return None + if isinstance(value, str): + return value or None + parts: list[str] = [] + + def walk(node: Any) -> None: + if isinstance(node, dict): + if node.get("nodeType") == "text" and node.get("value"): + parts.append(str(node["value"])) + for child in node.get("content") or []: + walk(child) + elif isinstance(node, list): + for child in node: + walk(child) + + walk(value) + text = "".join(parts).strip() + return text or None + + +def native_symbol(native: str) -> str: + """An instrument symbol as the venue spells it: upper case. Gemini's + streams send them lower case; its REST accepts either.""" + return native.upper() + + +def candidate_event_tickers(symbol: str) -> list[str]: + """The event tickers an instrument symbol could belong to, likeliest first. + + A symbol is `GEMI--`, and both tickers may + contain hyphens (`GEMI-NBA-2610232000-CHI-MIL-M-MIL`), so the event is + found by asking: the symbol with its last segment dropped first, then two, + and so on. Whichever event lists the symbol is the answer; nothing is + traded on a guess.""" + symbol = native_symbol(symbol) + if not symbol.startswith(SYMBOL_PREFIX): + raise BadRequest(f"{VENUE}: {symbol!r} is not an instrument symbol; they start with {SYMBOL_PREFIX!r}") + parts = symbol[len(SYMBOL_PREFIX):].split("-") + return ["-".join(parts[:end]) for end in range(len(parts) - 1, 0, -1)] + + +def status_of(event_status: Any, contract_status: Any = None, market_state: Any = None, + expiry: int | None = None, now_ms: int | None = None) -> tuple[str, bool]: + """(normalized status, accepting orders). + + `approved` is listed but not yet trading. An `active` contract whose + `marketState` is `closed` has stopped trading: closed once its expiry has + passed, a pause before it. `under_review` is awaiting its result. + `invalid` was voided and is final.""" + status = str(contract_status or event_status or "").lower() + if status in ("settled", "invalid"): + return "settled", False + if status in ("closed", "under_review"): + return "closed", False + if status == "approved": + return "unopened", False + if status == "active": + if str(market_state or "open").lower() == "open": + return "open", True + import time + + now = now_ms if now_ms is not None else int(time.time() * 1000) + return ("closed" if expiry is not None and expiry <= now else "open"), False + return "unopened", False + + +def quotes_of(prices: dict[str, Any] | None) -> tuple[Quote, Quote]: + """YES and NO top of book from a contract's `prices`. + + `bestBid` and `bestAsk` are the YES book; NO is its mirror. Sizes are not + in the event payload. The last trade price has no time beside it, so it is + left out of the quote (it is in `info["prices"]`): a last price is never + reported without when it traded.""" + prices = prices or {} + bid, ask = quoted(prices.get("bestBid")), quoted(prices.get("bestAsk")) + mid = round((bid + ask) / 2, 6) if bid is not None and ask is not None else None + yes = Quote(bid=bid, ask=ask, mid=mid) + no = Quote( + bid=round(1 - ask, 6) if ask is not None else None, + ask=round(1 - bid, 6) if bid is not None else None, + mid=round(1 - mid, 6) if mid is not None else None, + ) + return yes, no + + +def _event_summary(event: dict[str, Any]) -> dict[str, Any]: + """The event's own fields a market carries, without its contracts and children.""" + keep = ( + "id", "ticker", "title", "type", "category", "series", "status", "startTime", "expiryDate", "isLive", + "gameId", "template", "sportsMarket", "participants", "sourceDetails", "subcategory", "volume", + "volume24h", "liquidity", "tags", + ) + return {key: event[key] for key in keep if key in event} + + +def _tags(event: dict[str, Any]) -> list[str]: + names: list[Any] = [event.get("category")] + names += list((event.get("subcategory") or {}).get("path") or []) + names += list(event.get("tags") or []) + sports = event.get("sportsMarket") or {} + names += [sports.get("sport"), sports.get("type")] + seen: dict[str, None] = {} + for name in names: + if name and str(name).strip(): + seen.setdefault(str(name).strip().lower(), None) + return list(seen) + + +def _sources(event: dict[str, Any]) -> list[dict[str, Any]]: + details = event.get("sourceDetails") or {} + if not details.get("agency") and not details.get("index"): + return [] + return [{"name": details.get("agency"), "index": details.get("index"), "url": None}] + + +def _exclusive(event: dict[str, Any]) -> bool | None: + """Exactly one contract wins a moneyline (draw included where listed) or a + correct score. Nothing else is said by the venue, so nothing else is + claimed.""" + family = (event.get("sportsMarket") or {}).get("type") + return True if family in ("moneyline", "correct_score") else None + + +def normalize_market(contract: dict[str, Any], event: dict[str, Any] | None = None, *, + now_ms: int | None = None) -> Market: + """One contract as a binary market: YES is the contract's proposition.""" + event = event or {} + symbol = native_symbol(str(contract.get("instrumentSymbol") or "")) + event_ticker = event.get("ticker") + label = str(contract.get("label") or contract.get("ticker") or symbol) + title = str(event.get("title") or label) + single = len(event.get("contracts") or []) <= 1 + if not single and label and label != title: + title = f"{title}: {label}" + expiry = parse_ts(contract.get("expiryDate") or event.get("expiryDate")) + status, active = status_of(event.get("status"), contract.get("status"), contract.get("marketState"), + expiry, now_ms) + yes_quote, no_quote = quotes_of(contract.get("prices")) + opened = parse_ts(contract.get("effectiveDate") or event.get("effectiveDate")) + resolved = parse_ts(contract.get("resolvedAt")) + tags = _tags(event) + description = plain_text(contract.get("description")) or plain_text(event.get("description")) + return Market( + id=ids.qualify(VENUE, symbol), + venue=VENUE, + venue_market_id=symbol, + event_id=ids.qualify(VENUE, event_ticker) if event_ticker else None, + title=title, + description=description, + slug=symbol.lower(), + yes=Outcome(label=label, quote=yes_quote), + no=Outcome(label="No" if label.lower() == "yes" else f"Not {label}", quote=no_quote), + status=status, # type: ignore[arg-type] + native_status="/".join(str(s) for s in (contract.get("status"), contract.get("marketState")) if s) or None, + active=active, + market_type="binary", + open_timestamp=opened, + open_datetime=iso(opened), + close_timestamp=expiry, + close_datetime=iso(expiry), + resolution_timestamp=None, + resolution_datetime=None, + tick_size=to_float(contract.get("priceIncrement")), + face_value=1.0, + book_model="shared_complement", + # Gemini reports volume per event, in USD, not per contract: see the event. + stats=MarketStats(), + url=None, + image_url=contract.get("imageUrl") or event.get("imageUrl") or None, + category=event.get("category"), + tags=tags, + series_id=event.get("series") or None, + outcome_label=label, + neg_risk=None, + settlement_sources=_sources(event), + info={ + "contract": {k: v for k, v in contract.items() if k != "description"}, + "event": _event_summary(event), + "event_ticker": event_ticker, + "contract_ticker": contract.get("ticker"), + "contract_id": contract.get("id"), + "sports_market": event.get("sportsMarket"), + "strike": contract.get("strike"), + "prices": contract.get("prices"), + "last_trade_price": to_float((contract.get("prices") or {}).get("lastTradePrice")), + "quantity_increment": contract.get("quantityIncrement"), + "quantity_minimum": contract.get("quantityMinimum"), + "price_increment": contract.get("priceIncrement"), + "price_minimum": contract.get("priceMinimum"), + "market_state": contract.get("marketState"), + "resolution_side": contract.get("resolutionSide"), + "resolved_at": resolved, + "settlement_value": contract.get("settlementValue"), + "terms_url": contract.get("termsAndConditionsUrl") or event.get("termsLink"), + "is_combo": contract.get("isCombo"), + "listed_at": parse_ts(contract.get("createdAt")), + "currency": "USD", + }, + ) + + +def normalize_event(event: dict[str, Any], *, now_ms: int | None = None) -> Event: + """One Gemini event with its own contracts. Child events (a game's spreads, + totals and props) are separate events; their tickers are in + `info["child_events"]`.""" + ticker = str(event.get("ticker") or "") + markets = [normalize_market(c, event, now_ms=now_ms) for c in event.get("contracts") or [] + if c.get("instrumentSymbol")] + statuses = {m.status for m in markets} + status = next((s for s in ("open", "unopened", "closed", "settled") if s in statuses), None) + if status is None: + status, _ = status_of(event.get("status"), now_ms=now_ms) + close = parse_ts(event.get("expiryDate")) + return Event( + id=ids.qualify(VENUE, ticker), + venue=VENUE, + venue_event_id=ticker, + title=str(event.get("title") or ticker), + description=plain_text(event.get("description")), + slug=event.get("slug"), + markets=markets, + status=status, # type: ignore[arg-type] + native_status=event.get("status"), + category=event.get("category"), + tags=_tags(event), + series_id=event.get("series") or None, + mutually_exclusive=_exclusive(event), + close_timestamp=close, + close_datetime=iso(close), + url=None, + image_url=event.get("imageUrl") or None, + settlement_sources=_sources(event), + info={ + **_event_summary(event), + "sports_market": event.get("sportsMarket"), + "child_events": [str(child.get("ticker")) for child in event.get("events") or [] if child.get("ticker")], + "volume_usd": to_float(event.get("volume")), + "volume_24h_usd": to_float(event.get("volume24h")), + "liquidity_usd": to_float(event.get("liquidity")), + "start_timestamp": parse_ts(event.get("startTime")), + "currency": "USD", + }, + ) + + +def normalize_order_book(payload: dict[str, Any], *, symbol: str, side: BookSide = "yes", + depth: int | None = None) -> OrderBook: + """`/v1/book/{symbol}` read on YES: bids are YES bids, asks YES asks. + `side="no"` mirrors it.""" + def levels(rows: Any) -> list[OrderLevel]: + out = [] + for row in rows or []: + price, size = quoted((row or {}).get("price")), to_float((row or {}).get("amount")) + if price is None or size is None or size <= 0: + continue + out.append(OrderLevel(price=price, size=size)) + return out + + bids = sorted(levels(payload.get("bids")), key=lambda level: level.price, reverse=True) + asks = sorted(levels(payload.get("asks")), key=lambda level: level.price) + if side == "no": + bids, asks = ( + [OrderLevel(price=round(1 - level.price, 6), size=level.size) for level in asks], + [OrderLevel(price=round(1 - level.price, 6), size=level.size) for level in bids], + ) + elif side != "yes": + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + if depth: + bids, asks = bids[:depth], asks[:depth] + stamps = [int(row["timestamp"]) for key in ("bids", "asks") for row in payload.get(key) or [] + if str((row or {}).get("timestamp") or "").isdigit()] + stamp = max(stamps) * 1000 if stamps else None + return OrderBook( + market_id=ids.qualify(VENUE, native_symbol(symbol)), side=side, venue=VENUE, bids=bids, asks=asks, + timestamp=stamp, datetime=iso(stamp), book_model="shared_complement", derived=side == "no", + depth_scope="top_n" if depth else "full", info=payload, + ) + + +def normalize_trade(row: dict[str, Any], *, symbol: str) -> Trade: + """A public trade in the YES price. Gemini's `type` is the taker's side: + `buy` lifted an ask (took YES), `sell` hit a bid (took NO).""" + stamp = int(row.get("timestampms") or int(row.get("timestamp") or 0) * 1000) + kind = row.get("type") + return Trade( + id=str(row.get("tid") or ""), + market_id=ids.qualify(VENUE, native_symbol(symbol)), + timestamp=stamp, + datetime=iso(stamp) or "", + price=to_float(row.get("price")) or 0.0, + quantity=to_float(row.get("amount")) or 0.0, + side=kind if kind in ("buy", "sell") else "unknown", + info=row, + ) + + +def normalize_candle(row: list[Any] | tuple[Any, ...], *, interval_seconds: int) -> Candle: + """One `/v2/candles` row `[start ms, open, high, low, close, volume]`. + + Gemini fills a period without trades with the last close at zero volume; + such a bar is labelled `sampled_last`, not `trade`.""" + start, open_, high, low, close, volume = (list(row) + [None] * 6)[:6] + traded = bool(volume) + return Candle( + timestamp=int(start), + datetime=iso(int(start)) or "", + open=to_float(open_), high=to_float(high), low=to_float(low), close=to_float(close), + volume=to_float(volume) if traded else None, + price_source="trade" if traded else "sampled_last", + info={"row": list(row), "interval_seconds": interval_seconds}, + ) + + +def fee_schedule_of(contract: dict[str, Any] | None = None, *, symbol: str = "", now_ms: int | None = None) -> FeeSchedule: + """The schedule a contract trades on, from when it was listed. + + Listed from 18 March 2026: `rate * C * P * (1 - P)`, taker 0.07, maker + 0.0175 (`quadratic_theta`). Before: `rate * C * min(P, 1 - P)`, taker + 0.05, maker 0.01 (`min_price`). Both round the fee up to the cent. During + Gemini's maker promotion (2 September to 31 December 2026) the maker rate + is 0.""" + import time + + contract = contract or {} + listed = parse_ts(contract.get("createdAt")) + now = now_ms if now_ms is not None else int(time.time() * 1000) + legacy = listed is not None and listed < SCHEDULE_FROM + promo = MAKER_PROMO[0] <= now < MAKER_PROMO[1] + maker = 0.0 if promo else (LEGACY_MAKER_RATE if legacy else MAKER_RATE) + return FeeSchedule( + venue=VENUE, scope="market", scope_id=native_symbol(symbol or str(contract.get("instrumentSymbol") or "")), + fee_type="min_price" if legacy else "quadratic_theta", + taker_rate=LEGACY_TAKER_RATE if legacy else TAKER_RATE, + maker_rate=maker, + exponent=None if legacy else 1.0, + rounding="up_to_cent", + info={ + "schedule": "legacy (contracts listed before 2026-03-18)" if legacy else "2026-03-12", + "listed_at": listed, + "maker_promotion": promo, + "source": FEE_URL, + }, + ) + + +def _fingerprint(**terms: Any) -> str: + material = json.dumps(terms, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(material.encode()).hexdigest()[:8] + + +def encode_cursor(offset: int, skip: int, fingerprint: str) -> str: + """The venue pages by offset only, so a cursor carries the event offset, + how many markets of the next event were already returned, and the query + it counts rows of.""" + packed = json.dumps({"o": offset, "s": skip, "f": fingerprint}, separators=(",", ":")) + return base64.urlsafe_b64encode(packed.encode()).decode().rstrip("=") + + +def decode_cursor(cursor: str | None, fingerprint: str) -> tuple[int, int]: + if not cursor: + return 0, 0 + try: + padded = cursor + "=" * (-len(cursor) % 4) + state = json.loads(base64.urlsafe_b64decode(padded.encode())) + offset, skip, carried = state["o"], state["s"], state["f"] + except Exception: + raise BadRequest( + f"{VENUE}: {cursor!r} is not a cursor this API issued; pass the `next_cursor` from a previous " + f"page, or omit it to start over" + ) from None + if not all(isinstance(n, int) and not isinstance(n, bool) and n >= 0 for n in (offset, skip)): + raise BadRequest(f"{VENUE}: malformed cursor {cursor!r}") + if carried != fingerprint: + raise BadRequest( + f"{VENUE}: this cursor belongs to a different query; keep the same filters while paging, or " + f"start over without a cursor" + ) + return offset, skip + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class Gemini(Exchange): + """Gemini Predictions, read-only. No account needed. + + ```python + import synpath + + gemini = synpath.Gemini() + markets = gemini.fetch_markets(sport="basketball", sports_market_type="moneyline", limit=10) + book = gemini.fetch_order_book(markets[0].id) + markets[0].info["sports_market"] # {"sport": "basketball", "type": "moneyline", ...} + ``` + """ + + id = VENUE + name = "Gemini" + book_model = "shared_complement" + has: dict[str, Capability] = { + "fetch_markets": True, + "fetch_events": True, + "fetch_market": True, + "fetch_markets_by_ids": True, + # The events listing takes no sort parameter. + "sort": False, + "fetch_order_book": True, + # No batch book endpoint: one request per market. + "fetch_order_books": True, + # Seven days of public trades, 500 a request, paged forward by trade id. + "fetch_trades": True, + # Fixed windows per timeframe (a day of 1m bars), no range parameters. + "fetch_ohlcv": "partial", + "fetch_series": False, + "fetch_fee_schedule": True, + "search": True, + "match_market": False, + "match_event": False, + } + + def __init__( + self, *, base_url: str = API_URL, sandbox: bool = False, timeout: float = 30.0, + limiter: RateLimiter | None = LIMITER, client: Any = None, + ): + """No argument is needed: market data is public. `sandbox=True` reads + Gemini's sandbox (`api.sandbox.gemini.com`) instead.""" + url = SANDBOX_URL if sandbox and base_url == API_URL else base_url + self.http = HttpClient(url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + self._events_of: OrderedDict[str, str] = OrderedDict() + + # -- raw reads ---------------------------------------------------------- + + def _remember(self, event: dict[str, Any]) -> None: + ticker = event.get("ticker") + for contract in event.get("contracts") or []: + symbol = contract.get("instrumentSymbol") + if symbol and ticker: + self._events_of[native_symbol(str(symbol))] = str(ticker) + self._events_of.move_to_end(native_symbol(str(symbol))) + while len(self._events_of) > SYMBOL_CACHE: + self._events_of.popitem(last=False) + + def fetch_gemini_event(self, ticker: str) -> dict[str, Any]: + """One raw event by ticker, with its contracts and child events.""" + event = self.http.get(f"/v1/prediction-markets/events/{ticker}") or {} + self._remember(event) + for child in event.get("events") or []: + self._remember(child) + return event + + def _event_page(self, *, statuses: tuple[str, ...] | None, query: str | None, filters: dict[str, Any], + offset: int, limit: int) -> tuple[list[dict[str, Any]], int | None]: + params: list[tuple[str, Any]] = [("status", s) for s in statuses or ()] + for name, value in filters.items(): + values = value if isinstance(value, (list, tuple)) else [value] + params += [(name, v) for v in values if v is not None] + if query: + params.append(("search", query)) + params += [("limit", str(limit)), ("offset", str(offset))] + body = self.http.get("/v1/prediction-markets/events", params) or {} + events = [e for e in body.get("data") or [] if isinstance(e, dict) and e.get("ticker")] + for event in events: + self._remember(event) + total = (body.get("pagination") or {}).get("total") + return events, total if isinstance(total, int) else None + + def _walk(self, *, statuses: tuple[str, ...] | None, query: str | None, filters: dict[str, Any], + offset: int, ended: list[bool], page: int = EVENT_PAGE) -> Iterator[tuple[int, dict[str, Any]]]: + """(offset, event) from `offset` on, at most `MAX_PAGES` requests. + `ended` gets `True` once the listing has no more events.""" + for _ in range(MAX_PAGES): + events, total = self._event_page(statuses=statuses, query=query, filters=filters, offset=offset, + limit=page) + for index, event in enumerate(events): + yield offset + index, event + offset += len(events) + if len(events) < page or (total is not None and offset >= total): + ended.append(True) + return + + @staticmethod + def _filters(category: Any, sport: Any, sports_market_type: Any, sports_market_subject: Any, + sports_market_scope: Any, sports_market_metric: Any) -> dict[str, Any]: + return { + "category": category, "sport": sport, "sports_market_type": sports_market_type, + "sports_market_subject": sports_market_subject, "sports_market_scope": sports_market_scope, + "sports_market_metric": sports_market_metric, + } + + # -- catalog ------------------------------------------------------------ + + def fetch_markets( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", sort: str | None = None, category: str | list[str] | None = None, + sport: str | list[str] | None = None, sports_market_type: str | list[str] | None = None, + sports_market_subject: str | list[str] | None = None, sports_market_scope: str | list[str] | None = None, + sports_market_metric: str | list[str] | None = None, + ) -> Page[Market]: + """One page of contracts (each a market), events in the venue's order, + each event's contracts in its order. + + `query` is the venue's title search. `category` is one of + `/v1/prediction-markets/categories` (`Sports`, `Crypto`, `Politics`). + The sports filters are the venue's taxonomy: `sport` (`basketball`, + `soccer`), `sports_market_type` (`moneyline`, `spread`, `total`, + `prop`, `correct_score`, `to_advance`, `futures`), + `sports_market_subject`, `sports_market_scope` and + `sports_market_metric`; a list matches any of its values. The venue + pages by offset, so a contract listed mid-walk can shift the rest by + one; the cursor refuses to resume a different query.""" + check_sort(sort, venue=VENUE, supported=False) + check_status(status) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + filters = self._filters(category, sport, sports_market_type, sports_market_subject, sports_market_scope, + sports_market_metric) + fingerprint = _fingerprint(kind="markets", status=status, query=query, **filters) + offset, skip = decode_cursor(cursor, fingerprint) + out: list[Market] = [] + ended: list[bool] = [] + resume = offset + for position, event in self._walk(statuses=STATUSES[status], query=query, filters=filters, offset=offset, + ended=ended): + # Every contract is counted for the cursor, the ones the status + # filter drops included, so a resumed page lines up. + contracts = normalize_event(event).markets + already = skip if position == offset else 0 + for index in range(already, len(contracts)): + if len(out) >= wanted: + return Page(out, next_cursor=encode_cursor(position, index, fingerprint)) + market = contracts[index] + if status == "all" or market.status == status: + out.append(market) + resume = position + 1 + if ended: + return Page(out, next_cursor=None) + # Stopped on the request budget, not at the end: a short page with a cursor. + return Page(out, next_cursor=encode_cursor(resume, 0, fingerprint)) + + def fetch_events( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", category: str | list[str] | None = None, sport: str | list[str] | None = None, + sports_market_type: str | list[str] | None = None, sports_market_subject: str | list[str] | None = None, + sports_market_scope: str | list[str] | None = None, sports_market_metric: str | list[str] | None = None, + ) -> Page[Event]: + """One page of events with their contracts. Filters as `fetch_markets`.""" + check_status(status) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + filters = self._filters(category, sport, sports_market_type, sports_market_subject, sports_market_scope, + sports_market_metric) + fingerprint = _fingerprint(kind="events", status=status, query=query, **filters) + offset, _ = decode_cursor(cursor, fingerprint) + events, total = self._event_page(statuses=STATUSES[status], query=query, filters=filters, offset=offset, + limit=wanted) + more = len(events) == wanted and (total is None or offset + len(events) < total) + return Page([normalize_event(e) for e in events], + next_cursor=encode_cursor(offset + len(events), 0, fingerprint) if more else None) + + def fetch_event(self, event_id: str) -> Event: + """One event by ticker (`gemini:NBA-2610232000-CHI-MIL-M`), any status.""" + ticker = self.native(event_id) + return normalize_event(self.fetch_gemini_event(ticker)) + + def event_ticker_of(self, market_id: str, *, fetched: dict[str, dict[str, Any]] | None = None) -> str: + """The event ticker an instrument symbol belongs to: remembered from a + listing, else found by asking the candidate events in turn. Events + read on the way are added to `fetched`, by ticker.""" + symbol = native_symbol(self.native(market_id)) + known = self._events_of.get(symbol) + if known: + return known + for ticker in candidate_event_tickers(symbol): + try: + event = self.fetch_gemini_event(ticker) + except MarketNotFound: + continue + if fetched is not None: + fetched[str(event.get("ticker") or ticker)] = event + for child in event.get("events") or []: + if child.get("ticker"): + fetched.setdefault(str(child["ticker"]), child) + if symbol in self._events_of: + return self._events_of[symbol] + raise MarketNotFound(f"{VENUE}: no event lists {symbol}") + + def fetch_market(self, market_id: str) -> Market: + """One contract by instrument symbol, any status.""" + found = self.fetch_markets_by_ids([market_id]) + if not found: + raise MarketNotFound(f"{VENUE}: no contract {native_symbol(self.native(market_id))}") + return found[0] + + def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: + """Contracts in the order asked for, one event request per event + (symbols of the same event share it). Symbols no event lists are left + out.""" + symbols = list(dict.fromkeys(native_symbol(self.native(m)) for m in market_ids)) + events: dict[str, dict[str, Any]] = {} + out: list[Market] = [] + for symbol in symbols: + try: + ticker = self.event_ticker_of(symbol, fetched=events) + except (MarketNotFound, BadRequest): + continue + if ticker not in events: + try: + events[ticker] = self.fetch_gemini_event(ticker) + except MarketNotFound: + events[ticker] = {} + event = events[ticker] + contract = next((c for c in event.get("contracts") or [] + if native_symbol(str(c.get("instrumentSymbol") or "")) == symbol), None) + if contract is None: + for child in event.get("events") or []: + contract = next((c for c in child.get("contracts") or [] + if native_symbol(str(c.get("instrumentSymbol") or "")) == symbol), None) + if contract is not None: + event = child + break + if contract is not None: + out.append(normalize_market(contract, event)) + return out + + def fetch_categories(self, *, status: str | None = None) -> list[str]: + """The venue's event categories (`Sports`, `Crypto`, ...), optionally + only those with events in one of Gemini's own statuses (`active`).""" + body = self.http.get("/v1/prediction-markets/categories", {"status": status}) or {} + return [str(c) for c in body.get("categories") or []] + + def fetch_strike(self, event_id: str) -> dict[str, Any]: + """The strike of a crypto Up/Down event, once the venue sets it: + `{"value", "type", "availableAt"}`.""" + return self.http.get(f"/v1/prediction-markets/events/{self.native(event_id)}/strike") or {} + + # -- market data -------------------------------------------------------- + + def fetch_order_book(self, market_id: str, *, side: BookSide = "yes", depth: int | None = None) -> OrderBook: + """A contract's whole book on YES (`depth` levels a side when given).""" + symbol = native_symbol(self.native(market_id)) + params = {"limit_bids": depth or 0, "limit_asks": depth or 0} + payload = self.http.get(f"/v1/book/{symbol}", params) or {} + return normalize_order_book(payload, symbol=symbol, side=side, depth=depth) + + def fetch_order_books( + self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None, + ) -> dict[str, OrderBook]: + """Books for many contracts, one request each (the venue has no batch book).""" + out: dict[str, OrderBook] = {} + for market_id in dict.fromkeys(market_ids): + book = self.fetch_order_book(market_id, side=side, depth=depth) + out[book.market_id] = book + return out + + def fetch_trades( + self, market_id: str, *, since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Trade]: + """Public trades, oldest first, from the last seven days (the venue's + window). Without `since` or `cursor`, the most recent `limit`. With + `cursor` (a trade id from `next_cursor`), the next `limit` after it; + `since` (ms) starts from a time. `next_cursor` is set when a page came + back full.""" + symbol = native_symbol(self.native(market_id)) + wanted = max(1, min(limit or TRADES_PAGE, TRADES_PAGE)) + params: dict[str, Any] = {"limit_trades": wanted} + if cursor: + if not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a trades cursor this API issued") + params["since_tid"] = cursor + elif since is not None: + params["timestamp"] = int(since) + rows = self.http.get(f"/v1/trades/{symbol}", params) or [] + trades = sorted((normalize_trade(row, symbol=symbol) for row in rows if not row.get("broken")), + key=lambda t: (t.timestamp, int(t.id) if t.id.isdigit() else 0)) + if since is not None: + trades = [t for t in trades if t.timestamp >= since] + forward = bool(cursor) or since is not None + last_id = max((int(row["tid"]) for row in rows if str(row.get("tid") or "").isdigit()), default=None) + next_cursor = str(last_id) if forward and len(rows) >= wanted and last_id is not None else None + return Page(trades, next_cursor=next_cursor) + + def fetch_ohlcv( + self, market_id: str, *, timeframe: str = "1h", since: int | None = None, until: int | None = None, + limit: int | None = None, + ) -> list[Candle]: + """Candles in the YES price, oldest first, from `/v2/candles`. + + The venue serves a fixed recent window per timeframe (about a day of + 1m bars) with no range parameters, so `since` and `until` filter that + window. Timeframes: 1m, 5m, 15m, 30m, 1h, 4h, 6h, 1d. A bar with no + trades repeats the last close and is labelled `sampled_last`.""" + if timeframe not in CANDLE_FRAMES: + raise BadRequest(f"{VENUE}: unknown timeframe {timeframe!r}; expected one of {', '.join(CANDLE_FRAMES)}") + from .base import timeframe_seconds + + seconds = timeframe_seconds(timeframe) + symbol = native_symbol(self.native(market_id)) + rows = self.http.get(f"/v2/candles/{symbol}/{CANDLE_FRAMES[timeframe]}") or [] + candles = sorted((normalize_candle(row, interval_seconds=seconds) for row in rows + if isinstance(row, (list, tuple)) and row), key=lambda c: c.timestamp) + if since is not None: + candles = [c for c in candles if c.timestamp >= since] + if until is not None: + candles = [c for c in candles if c.timestamp < until] + return pick_bars(candles, since=since, limit=limit) + + # -- reference ---------------------------------------------------------- + + def fetch_fee_schedule(self, market_id: str) -> FeeSchedule: + """The fee schedule the contract trades on, from its listing date (one + event read). See `fee_schedule_of`.""" + market = self.fetch_market(market_id) + return fee_schedule_of(market.info.get("contract") or {}, symbol=market.venue_market_id) + + def close(self) -> None: + self.http.close() diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..8dbfc8a 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -4,7 +4,8 @@ the venue's own id, separated by a colon: `kalshi:KXFEDDECISION-26SEP-C25`, `polymarket:2252244`, `polymarket_us:tec-mlb-nlchamp-2026-09-27-atl`, `opinion:5342`, `hyperliquid:7173`, `betfair:1.234567890-47972`, -`smarkets:164973231-456060183`. Nothing is hashed or re-keyed, so the part +`smarkets:164973231-456060183`, +`gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`. Nothing is hashed or re-keyed, so the part after the colon is what the venue's own website and API call the thing. The venue in the id is what lets one field route a call: `synpath.Client` @@ -16,7 +17,7 @@ """ from __future__ import annotations -VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") +VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets", "gemini") """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.""" diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 6743665..cc498fd 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -35,7 +35,7 @@ log = logging.getLogger("synpath.serve") -VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet") +VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "gemini") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index aad1025..b1eb084 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -81,12 +81,15 @@ def __getattr__(name: str): if name == "SxBetTrading": from .sx_bet import SxBetTrading return SxBetTrading + if name == "GeminiTrading": + from .gemini import GeminiTrading + return GeminiTrading raise AttributeError(name) __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "GeminiTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index ab2f240..363e634 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -45,6 +45,10 @@ def doctor(dotenv: str | None) -> int: "SMARKETS_PASSWORD for undelayed quotes, or run synpath init. Betting needs Smarkets' " "approved API access (£150 one-off), not supported yet")) continue + if creds is None and venue == "gemini": + rows.append((venue, "no key (optional)", "market data needs no key; set GEMINI_API_KEY / " + "GEMINI_API_SECRET to trade (a US account with Prediction Markets access), or run synpath init")) + continue if creds is None and venue in market_data: stage = "testnet" if market_data[venue].testnet else "mainnet" private = ENV_NAMES[venue][0] @@ -91,6 +95,9 @@ def doctor(dotenv: str | None) -> int: stage = creds.jurisdiction login = "certificate" if creds.cert_path else "interactive" identity = f"user {creds.username}, app key loaded, {login} login" + elif venue == "gemini": + sub = f", subaccount {creds.account}" if creds.account else "" + identity = f"api key {creds.api_key[:12]}…{sub} (account-scoped, time-based nonce for streams)" elif venue == "smarkets": stage = "login" identity = f"user {creds.username}: undelayed quotes; betting needs approved API access, not supported yet" diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..8b2dbfb 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -45,6 +45,9 @@ (certificate login) and BETFAIR_JURISDICTION Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market (market data) data needs no login; one gives undelayed quotes + Gemini GEMINI_API_KEY, GEMINI_API_SECRET, and optionally + GEMINI_ACCOUNT (a master key's subaccount) and GEMINI_ENV + (prod, the default, or sandbox); market data needs neither """ from __future__ import annotations @@ -357,10 +360,36 @@ def secrets(self) -> list[str]: return [self.password] +@dataclass(frozen=True, repr=False) +class GeminiCredentials: + """Gemini Predictions: an API key pair from exchange.gemini.com/settings/api. + + Account-scoped, with the Trader role and a time-based nonce: the + WebSocket takes no other key, and the REST nonces here are Unix seconds. + The account needs Prediction Markets access (US) and the current + Prediction Markets terms accepted.""" + + api_key: str + """The key id, `account-...` (or `master-...` for a master key).""" + api_secret: str = field(repr=False) + """The secret shown once at creation. Signs every private request (HMAC-SHA384).""" + account: str | None = None + """The subaccount a master key acts for (its short name). Unset for an + account-scoped key.""" + env: Literal["prod", "sandbox"] = "prod" + """`prod` trades on Gemini itself; `sandbox` on api.sandbox.gemini.com with a sandbox key.""" + _public = ("api_key", "account", "env") + __repr__ = _redacted_repr + + @property + def secrets(self) -> list[str]: + return [self.api_secret] + + Credentials = ( KalshiCredentials | PolymarketCredentials | PolymarketUSCredentials | PolymarketUSExchangeCredentials | OpinionCredentials | HyperliquidCredentials | PredictFunCredentials | LimitlessCredentials | SxBetCredentials - | BetfairCredentials | SmarketsCredentials + | BetfairCredentials | SmarketsCredentials | GeminiCredentials ) ENV_NAMES: dict[str, tuple[str, ...]] = { @@ -389,6 +418,7 @@ def secrets(self) -> list[str]: "BETFAIR_JURISDICTION", ), "smarkets": ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"), + "gemini": ("GEMINI_API_KEY", "GEMINI_API_SECRET", "GEMINI_ACCOUNT", "GEMINI_ENV"), } """What each venue reads. The first entry is the one whose absence means "not configured"; the rest are optional or have defaults.""" @@ -685,6 +715,25 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: return SmarketsCredentials(username=username, password=password) +def load_gemini(env: Mapping[str, str]) -> GeminiCredentials | None: + api_key = (env.get("GEMINI_API_KEY") or "").strip() + secret = (env.get("GEMINI_API_SECRET") or "").strip() + if not api_key: + if secret: + raise CredentialsMissing("GEMINI_API_SECRET is set but GEMINI_API_KEY is not") + return None + if not secret: + raise CredentialsMissing("GEMINI_API_KEY is set but GEMINI_API_SECRET is not; it is shown once when the key " + "is created (exchange.gemini.com/settings/api)") + if secret == api_key: + raise CredentialsMissing("GEMINI_API_SECRET is the same as GEMINI_API_KEY; the secret is the other half of the pair") + stage = (env.get("GEMINI_ENV") or "prod").strip().lower() + if stage not in ("prod", "sandbox"): + raise CredentialsMissing(f"GEMINI_ENV must be prod or sandbox, got {stage!r}") + account = (env.get("GEMINI_ACCOUNT") or "").strip() or None + return GeminiCredentials(api_key=api_key, api_secret=secret, account=account, env=stage) # type: ignore[arg-type] + + LOADERS = { "kalshi": load_kalshi, "polymarket": load_polymarket, @@ -697,6 +746,7 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: "sx_bet": load_sx_bet, "betfair": load_betfair, "smarkets": load_smarkets, + "gemini": load_gemini, } diff --git a/synpath/trading/gemini.py b/synpath/trading/gemini.py new file mode 100644 index 0000000..5edfe89 --- /dev/null +++ b/synpath/trading/gemini.py @@ -0,0 +1,807 @@ +"""Gemini Predictions order entry, on the private REST API (`api.gemini.com`). + +Trading needs a Gemini account with Prediction Markets access (US) and an +API key from exchange.gemini.com/settings/api: account-scoped, with the +Trader role, and a **time-based nonce** (the WebSocket accepts no other +kind). The account must also have accepted Gemini's current Prediction +Markets terms; `fetch_terms_status` says whether it has. + +Facts that shape the adapter: + +**Signing.** A private request sends no body. Its JSON payload, the request +path and a nonce merged into the endpoint's fields, goes base64-encoded in +`X-GEMINI-PAYLOAD`, and `X-GEMINI-SIGNATURE` is the hex HMAC-SHA384 of that +base64 string under the API secret. The nonce is Unix seconds (a time-based +key accepts it within 30 seconds of the server's clock). Signed requests +are sent one at a time, as Gemini's own SDK does, and a write is never +retried: a resend would be a second order. + +**An order names its outcome.** The wire carries `side` (`buy`, `sell`) and +`outcome` (`yes`, `no`), with the price in that outcome's terms; the book is +YES space. This library quotes the YES leg: `buy` at *p* is sent as buying +YES at *p*, `sell` at *p* as buying NO at `1 - p`. With `reduce_only` the +order sells what is held instead: `sell` sells YES at *p*, `buy` sells NO at +`1 - p` (Gemini's `sell` closes a position). Read back, every order is on +the YES leg again, the venue's own side and outcome kept in `info`. + +**Limit orders only.** The REST API takes `limit` (and `stop-limit`, which +the engine holds here). A market order is an immediate-or-cancel limit at the +caller's protection price, and one without a price is refused. Post-only is +`makerOrCancel`. There is no good-till-date and no client order id on the +wire; a `client_order_id` is kept on the returned `Order` only. + +**Fills.** The REST API has no fills endpoint. `fetch_my_trades` reads one +aggregate fill per order from the order history; the account stream +(`synpath.ws.gemini.GeminiUserStream`) delivers each execution. +""" +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import json +import time +from decimal import Decimal +from typing import Any + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, RateLimitExceeded +from ..gemini import ( + API_URL, SANDBOX_URL, candidate_event_tickers, fee_schedule_of, native_symbol, parse_ts, +) +from ..types import Page +from .base import TradingExchange +from .credentials import GeminiCredentials +from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, PermissionDenied +from .limiter import BudgetLimiter, Priority +from .money import D, complement, validate_price, validate_quantity +from .types import ( + Account, Balance, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, + PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "gemini" +BASE_URLS = {"prod": API_URL, "sandbox": SANDBOX_URL} +PREFIX = "/v1/prediction-markets" + +BATCH = 20 +"""Orders per batch place or cancel: the venue's limit.""" + +ACTIVE_PAGE = 100 +"""Most active orders per request.""" + +HISTORY_PAGE = 1000 +"""Most historical orders per request.""" + +READ_ATTEMPTS = 3 +"""Tries for a signed read that is rate limited; a write is sent once.""" + +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 without an instrument spec: the cent +tick and hundredth-contract grid of the contracts read so far. A contract +publishes its own (`priceIncrement`, `quantityIncrement`, +`quantityMinimum`); pass a `Precision` built from them for a finer grid.""" + +TIF_TO_WIRE = { + TimeInForce.GTC: "good-til-cancel", + TimeInForce.IOC: "immediate-or-cancel", + TimeInForce.FOK: "fill-or-kill", +} +TIF_FROM_WIRE = {wire: tif for tif, wire in TIF_TO_WIRE.items()} | {"maker-or-cancel": TimeInForce.GTC} + +STATUS = { + "open": OrderStatus.OPEN, + "filled": OrderStatus.CLOSED, + "closed": OrderStatus.CLOSED, + "cancelled": OrderStatus.CANCELED, + "canceled": OrderStatus.CANCELED, +} + +WS_STATUS = { + "NEW": OrderStatus.OPEN, + "OPEN": OrderStatus.OPEN, + "PARTIALLY_FILLED": OrderStatus.OPEN, + "MODIFIED": OrderStatus.OPEN, + "FILLED": OrderStatus.CLOSED, + "CANCELED": OrderStatus.CANCELED, + "CANCELLED": OrderStatus.CANCELED, + "REJECTED": OrderStatus.REJECTED, +} + + +# --------------------------------------------------------------------------- +# Signing +# --------------------------------------------------------------------------- + +class GeminiSigner: + """Holds the API secret and produces Gemini's private-API headers. + + REST: the payload is `{"request": , "nonce": , ...fields}` + as JSON, base64-encoded into `X-GEMINI-PAYLOAD`; the signature is + `hex(HMAC_SHA384(base64 payload, secret))`. WebSocket: the payload is + `base64(str(nonce))`, signed the same way, with the nonce also in + `X-GEMINI-NONCE`. The nonce is Unix seconds, what a time-based key takes. + """ + + def __init__(self, api_key: str, api_secret: str, *, account: str | None = None, clock: Any = time.time): + self.api_key = api_key + self._secret = api_secret.encode() + self.account = account + self._clock = clock + + def __repr__(self) -> str: + return f"GeminiSigner(api_key={self.api_key[:12]!r}..., secret=***)" + + def nonce(self) -> str: + return str(int(self._clock())) + + def sign(self, b64_payload: str) -> str: + return hmac.new(self._secret, b64_payload.encode(), hashlib.sha384).hexdigest() + + def payload(self, path: str, fields: dict[str, Any] | None = None, *, nonce: str | None = None) -> str: + """The base64 payload for `path` (no query string) and the endpoint's fields.""" + body: dict[str, Any] = {"request": path, "nonce": nonce if nonce is not None else self.nonce()} + if self.account and "account" not in (fields or {}): + body["account"] = self.account + body.update(fields or {}) + return base64.b64encode(json.dumps(body, separators=(",", ":")).encode()).decode() + + def headers(self, path: str, fields: dict[str, Any] | None = None, *, nonce: str | None = None) -> dict[str, str]: + encoded = self.payload(path, fields, nonce=nonce) + return { + "Content-Type": "text/plain", + "Content-Length": "0", + "X-GEMINI-APIKEY": self.api_key, + "X-GEMINI-PAYLOAD": encoded, + "X-GEMINI-SIGNATURE": self.sign(encoded), + "Cache-Control": "no-cache", + } + + def ws_headers(self, *, nonce: str | None = None) -> dict[str, str]: + stamp = nonce if nonce is not None else self.nonce() + encoded = base64.b64encode(stamp.encode()).decode() + return { + "X-GEMINI-APIKEY": self.api_key, + "X-GEMINI-NONCE": stamp, + "X-GEMINI-PAYLOAD": encoded, + "X-GEMINI-SIGNATURE": self.sign(encoded), + } + + +# --------------------------------------------------------------------------- +# Pure translation and normalizers +# --------------------------------------------------------------------------- + +def symbol_of(market_id: str) -> str: + """The instrument symbol behind a Synpath id (`gemini:GEMI-...`) or a bare + symbol. Another venue's id is refused before anything is signed.""" + try: + symbol = native_symbol(ids.native(VENUE, market_id)) + except BadRequest as exc: + raise InvalidOrder(str(exc)) from None + if not symbol.startswith("GEMI-"): + raise InvalidOrder(f"{VENUE}: {symbol!r} is not an instrument symbol (GEMI-...)") + return symbol + + +def _text(value: Decimal) -> str: + """A decimal as the venue's string, without an exponent.""" + return format(value, "f") + + +def wire_leg(side: Side, *, reduce_only: bool) -> tuple[str, str]: + """(venue side, outcome) for an order on the YES leg. See the module docstring.""" + if reduce_only: + return "sell", ("yes" if side == Side.SELL else "no") + return "buy", ("yes" if side == Side.BUY else "no") + + +def yes_leg(venue_side: str, outcome: str) -> Side: + """The YES-leg side of a venue order: buying NO or selling YES is a sell.""" + buying = str(venue_side).lower() == "buy" + yes = str(outcome).lower() in ("yes", "true") + return Side.BUY if buying == yes else Side.SELL + + +def yes_price(price: Any, outcome: str) -> Decimal | None: + if price in (None, ""): + return None + value = D(price) + return value if str(outcome).lower() in ("yes", "true") else complement(value) + + +def translate_order(request: OrderRequest, *, precision: Precision | None = None) -> dict[str, Any]: + """An `OrderRequest` as the `POST /v1/prediction-markets/order` fields. + + Validated here, before anything is signed: an order type the venue holds, + a price on the tick inside (0, 1), a quantity on the grid, a time in + force the venue knows.""" + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.time_in_force in (TimeInForce.DAY, TimeInForce.GTD): + raise InvalidOrder(f"{VENUE}: no good-till-date orders; use gtc and cancel, or the engine's expiry") + if request.price is None: + raise InvalidOrder( + f"{VENUE}: a price is required -- the venue takes limit orders only, so a market order is an " + f"immediate-or-cancel limit at the protection price you give" + ) + symbol = symbol_of(request.market_id) + spec = precision or DEFAULT_PRECISION + price = validate_price(D(request.price), spec) + quantity = validate_quantity(D(request.quantity), spec) + venue_side, outcome = wire_leg(request.side, reduce_only=request.reduce_only) + wire_price = price if outcome == "yes" else complement(price) + if request.type == OrderType.MARKET: + tif = TimeInForce.FOK if request.time_in_force == TimeInForce.FOK else TimeInForce.IOC + else: + tif = request.time_in_force + if request.post_only and tif != TimeInForce.GTC: + raise InvalidOrder(f"{VENUE}: a post-only (maker-or-cancel) order rests, so it is gtc") + body: dict[str, Any] = { + "symbol": symbol, + "orderType": "limit", + "side": venue_side, + "outcome": outcome, + "quantity": _text(quantity), + "price": _text(wire_price), + "timeInForce": TIF_TO_WIRE[tif], + } + if request.post_only: + body["makerOrCancel"] = True + return body + + +def order_of(raw: dict[str, Any], *, account: Account | None = None, request: OrderRequest | None = None, + body: dict[str, Any] | None = None) -> Order: + """A REST order row (`OrderResponse`) on the YES leg: an order the venue + holds as buying NO at 0.30 reads as a sell at 0.70. `request` and `body` + (what was sent) fill in what a placement answer leaves out.""" + body = body or {} + outcome = str(raw.get("outcome") or body.get("outcome") or "yes") + venue_side = str(raw.get("side") or body.get("side") or "buy") + quantity = D(raw.get("quantity") or body.get("quantity") or "0") + filled = D(raw.get("filledQuantity") or "0") + remaining = raw.get("remainingQuantity") + wire_tif = str(raw.get("timeInForce") or body.get("timeInForce") or "") + tif = TIF_FROM_WIRE.get(wire_tif, TimeInForce.GTC) + average = yes_price(raw.get("avgExecutionPrice"), outcome) + status = STATUS.get(str(raw.get("status") or "").lower(), OrderStatus.OPEN) + order = Order( + id=str(raw.get("orderId") or ""), + client_order_id=raw.get("clientOrderId") or (request.client_order_id if request else None), + venue=VENUE, + account=account, + market_id=ids.qualify(VENUE, native_symbol(str(raw.get("symbol") or body.get("symbol") or ""))), + side=yes_leg(venue_side, outcome), + type=OrderType.LIMIT if request is None else request.type, + time_in_force=tif, + status=status, + held_by=HeldBy.VENUE, + price=yes_price(raw.get("price") or body.get("price"), outcome), + stop_price=yes_price(raw.get("stopPrice"), outcome), + quantity=quantity, + filled=filled, + remaining=D(remaining) if remaining not in (None, "") else None, + average_price=average if filled > 0 else None, + cost=(D(raw["avgExecutionPrice"]) * filled).quantize(Decimal("0.0001")) + if filled > 0 and raw.get("avgExecutionPrice") not in (None, "") else None, + fee_currency="USD", + post_only=wire_tif == "maker-or-cancel" or bool(body.get("makerOrCancel")), + reduce_only=venue_side.lower() == "sell", + created_at=parse_ts(raw.get("createdAt")), + updated_at=parse_ts(raw.get("cancelledAt") or raw.get("updatedAt")), + info={**raw, "venue_side": venue_side, "venue_outcome": outcome}, + ) + if request is not None: + order = order.model_copy(update={"book": request.book, "trader": request.trader, "tags": request.tags}) + return order + + +def fill_of_order(raw: dict[str, Any], *, account: Account | None = None) -> Fill | None: + """One aggregate fill for an order that has filled, at its average price. + `None` for an order with nothing filled.""" + filled = D(raw.get("filledQuantity") or "0") + outcome = str(raw.get("outcome") or "yes") + price = yes_price(raw.get("avgExecutionPrice") or raw.get("price"), outcome) + if filled <= 0 or price is None: + return None + order_id = str(raw.get("orderId") or "") + return Fill( + id=order_id, order_id=order_id, client_order_id=raw.get("clientOrderId"), venue=VENUE, account=account, + market_id=ids.qualify(VENUE, native_symbol(str(raw.get("symbol") or ""))), + side=yes_leg(str(raw.get("side") or "buy"), outcome), price=price, quantity=filled, + fee=None, fee_currency="USD", liquidity=Liquidity.UNKNOWN, settlement=SettlementState.CONFIRMED, + timestamp=parse_ts(raw.get("updatedAt") or raw.get("createdAt")) or 0, + info={**raw, "aggregate": True}, + ) + + +def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: + """Position rows (one per symbol and outcome) as one netted `Position` per + symbol: YES held is long, NO held is short, with both inventories kept. + `entry_price` is on the YES leg (a NO bought at 0.36 is 0.64).""" + by_symbol: dict[str, list[dict[str, Any]]] = {} + for row in rows: + by_symbol.setdefault(native_symbol(str(row.get("symbol") or "")), []).append(row) + out: list[Position] = [] + for symbol, held in by_symbol.items(): + inventory = {"yes": Decimal("0"), "no": Decimal("0")} + entry: dict[str, Decimal] = {} + realized = Decimal("0") + unrealized: Decimal | None = None + for row in held: + outcome = "no" if str(row.get("outcome")).lower() in ("no", "false") else "yes" + inventory[outcome] += D(row.get("totalQuantity") or "0") + if row.get("avgPrice") not in (None, ""): + entry[outcome] = D(row["avgPrice"]) + if row.get("realizedPl") not in (None, ""): + realized += D(row["realizedPl"]) + if row.get("unrealizedPnl") not in (None, ""): + unrealized = (unrealized or Decimal("0")) + D(row["unrealizedPnl"]) + net = inventory["yes"] - inventory["no"] + side = PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT + price = (entry.get("yes") if net > 0 else complement(entry["no"]) if net < 0 and "no" in entry else None) + resolution = next((r.get("resolutionSide") for r in held if r.get("resolutionSide")), None) + out.append(Position( + venue=VENUE, account=account, market_id=ids.qualify(VENUE, symbol), side=side, contracts=abs(net), + inventory_yes=inventory["yes"], inventory_no=inventory["no"], + entry_price=price, unrealized_pnl=unrealized, realized_pnl=realized, + resolved=resolution is not None, final=resolution is not None, + won=(resolution == ("yes" if net > 0 else "no")) if resolution and net != 0 else None, + info={"rows": held}, + )) + return out + + +def settlement_of(raw: dict[str, Any], *, account: Account | None = None) -> Settlement: + """A settled position. `position` is signed: positive held YES.""" + signed = D(raw.get("position") or "0") + outcome = str(raw.get("outcome") or ("yes" if signed >= 0 else "no")).lower() + result = str(raw.get("resolutionSide") or "").lower() or None + payout = D(raw["payout"]) if raw.get("payout") not in (None, "") else None + return Settlement( + venue=VENUE, account=account, + market_id=ids.qualify(VENUE, native_symbol(str(raw.get("instrumentSymbol") or ""))), + held=PositionSide.LONG if outcome == "yes" else PositionSide.SHORT, + result=result, + won=(outcome == result) if result in ("yes", "no") else None, + quantity=D(raw.get("positionQuantity") or abs(signed)), + cost=D(raw["costBasis"]) if raw.get("costBasis") not in (None, "") else None, + payout=payout, + pnl=D(raw["netProfit"]) if raw.get("netProfit") not in (None, "") else None, + timestamp=parse_ts(raw.get("settledAt")), + info=raw, + ) + + +def balance_of(rows: list[dict[str, Any]], *, account: Account, currency: str = "USD") -> Balance: + """The USD row of `/v1/balances`: `amount` is the confirmed total, + `available` what can be traded now.""" + row = next((r for r in rows or [] if str(r.get("currency") or "").upper() == currency), None) + if row is None: + return Balance(venue=VENUE, account=account, currency=currency, total=Decimal("0"), available=Decimal("0"), + info={"balances": rows}) + total, available = D(row.get("amount") or "0"), D(row.get("available") or "0") + return Balance( + venue=VENUE, account=account, currency=currency, total=total, available=available, + locked=total - available, timestamp=parse_ts(row.get("_timestamp")), info=row, + ) + + +def _code(body: Any) -> tuple[str, str]: + """(the venue's error code, its message) from Gemini's error payloads: + `{"result": "error", "reason", "message"}` (authentication), + `{"error", "message"}` (Prediction Markets), `{"error", "code"}`.""" + if not isinstance(body, dict): + return "", "" + code = str(body.get("code") or body.get("reason") or body.get("error") or "") + message = str(body.get("message") or body.get("error") or "") + return code, message + + +AUTH_CODES = { + "InvalidSignature", "InvalidNonce", "InvalidApiKey", "MissingApikeyHeader", "MissingPayloadHeader", + "MissingSignatureHeader", "MissingNonce", "AmbiguousAuthentication", "ApiKeyIpFilteringFailure", +} + + +def map_error(exc: ExchangeError, *, path: str = "") -> ExchangeError: + """The venue's error code as the typed error a caller branches on.""" + code, message = _code(exc.body) + text = f"{code} {message} {exc}".lower() + label = f"{VENUE}: {message or code or exc}" + if code in AUTH_CODES or code.startswith("Missing") and "header" in code.lower(): + return AuthenticationError(f"{VENUE}: credentials refused: {code}: {message}".rstrip(": "), + body=exc.body, status=exc.status) + if code == "TERMS_NOT_ACCEPTED": + return PermissionDenied( + f"{VENUE}: the account has not accepted Gemini's latest Prediction Markets terms; read them with " + f"fetch_terms() and accept on gemini.com or with accept_terms()", body=exc.body, status=exc.status, + ) + if code in ("ACCOUNT_GROUP_BLOCKED", "ACCOUNT_RESTRICTED_SELL_ONLY") or "not permitted to trade" in text: + return PermissionDenied(label, body=exc.body, status=exc.status) + if isinstance(exc, AuthenticationError): + return exc + if "insufficientfunds" in text.replace(" ", "") or "insufficient funds" in text: + return InsufficientFunds(label, body=exc.body, status=exc.status) + if "marketnotopen" in text.replace(" ", "") or "market is closed" in text or "paused" in text: + return MarketHalted(label, body=exc.body, status=exc.status) + if code == "OrderNotFound" or (isinstance(exc, MarketNotFound) and "/order" in path): + return OrderNotFound(label, body=exc.body, status=exc.status) + if isinstance(exc, MarketNotFound): + return exc + if isinstance(exc, BadRequest): + return OrderRejected(label, reason=code or None, info=exc.body if isinstance(exc.body, dict) else {}, + body=exc.body, status=exc.status) + return exc + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class GeminiTrading(TradingExchange): + """Gemini Predictions order entry. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.gemini import GeminiTrading + + creds = require("gemini", load_credentials()) + async with GeminiTrading(creds) as gemini: + balance = await gemini.fetch_balance() + ``` + """ + + id = VENUE + name = "Gemini" + has: dict[str, Capability] = { + "create_order": True, + "create_orders": True, + "cancel_order": True, + "cancel_orders": True, + # No cancel-all route: the active orders are listed and cancelled in batches. + "cancel_all_orders": True, + "edit_order": False, + # No single-order route: active orders, then the history, are searched. + "fetch_order": True, + "fetch_open_orders": True, + "fetch_orders": True, + # No fills route: one aggregate fill per order, from the history. + "fetch_my_trades": "partial", + "fetch_positions": True, + "fetch_balance": True, + "fetch_settlements": True, + "fetch_queue_position": False, + "fetch_fee_estimate": True, + # Combo RFQ is a WebSocket auction for market makers, in beta; not offered. + "rfq": False, + "split_merge": False, + # The account streams are `synpath.ws.gemini.GeminiUserStream`. + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: GeminiCredentials, + *, + account_name: str = "default", + base_url: str | None = None, + limiter: BudgetLimiter | None = None, + timeout: float = 30.0, + client: Any = None, + clock: Any = time.time, + ): + self.credentials = credentials + self.env = credentials.env + self.account = Account(venue=VENUE, name=account_name, subaccount=credentials.account) + self.base_url = (base_url or BASE_URLS[credentials.env]).rstrip("/") + self.signer = GeminiSigner(credentials.api_key, credentials.api_secret, account=credentials.account, + clock=clock) + # 600 private requests a minute, five a second recommended; reads and + # writes share the venue's count, so each gets half of ten a second. + self.limiter = limiter or BudgetLimiter(read_per_second=5, write_per_second=5) + self.http = AsyncHttpClient(self.base_url, limiter=None, timeout=timeout, client=client, venue=VENUE, + attempts=1) + self._gate = asyncio.Lock() + """One signed request at a time: a nonce that arrives after a later + one from the same key is refused.""" + + # -- transport ------------------------------------------------------------ + + async def _call( + self, path: str, fields: dict[str, Any] | None = None, *, method: str = "POST", + query: dict[str, Any] | None = None, kind: str = "read", priority: Priority = Priority.NORMAL, + ) -> Any: + """One signed call, paced by the budget, errors mapped. A read that is + rate limited is signed again and retried; a write is sent once.""" + attempts = READ_ATTEMPTS if kind == "read" else 1 + delay = 0.5 + for attempt in range(attempts): + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + async with self._gate: + headers = self.signer.headers(path, fields) + try: + return await self.http.request( + method, path, params={k: v for k, v in (query or {}).items() if v is not None} or None, + headers=headers, + ) + except RateLimitExceeded: + if attempt == attempts - 1: + raise + except ExchangeError as exc: + raise map_error(exc, path=path) from None + await asyncio.sleep(delay) + delay *= 2 + raise AssertionError("unreachable") # pragma: no cover + + async def _public(self, path: str, params: dict[str, Any] | None = None) -> Any: + await self.limiter.acquire(cost=1, kind="read") + return await self.http.request("GET", path, params=params) + + # -- terms ---------------------------------------------------------------- + + async def fetch_terms_status(self) -> dict[str, Any]: + """`{"hasAcceptedLatest", "acceptedVersion", "latestVersion"}` for the + account group. Orders are refused until the latest is accepted.""" + return await self._call(f"{PREFIX}/terms/status", method="GET") or {} + + async def fetch_terms(self) -> dict[str, Any]: + """The current Prediction Markets terms (public).""" + return await self._public(f"{PREFIX}/terms") or {} + + async def accept_terms(self) -> dict[str, Any]: + """Accept the latest Prediction Markets terms for the account group. + A legal agreement made on the account's behalf: read `fetch_terms()` + first. Never called by this library on its own.""" + return await self._call(f"{PREFIX}/terms/accept", kind="write") or {} + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest, *, precision: Precision | None = None) -> Order: + """Place one order. See the module docstring for how a sell and a + market order reach the wire.""" + body = translate_order(request, precision=precision) + raw = await self._call(f"{PREFIX}/order", body, kind="write") + order = order_of(raw or {}, account=request.account or self.account, request=request, body=body) + return order.model_copy(update={"info": {**order.info, "request": body}}) + + async def create_orders(self, requests: list[OrderRequest], *, precision: Precision | None = None) -> list[Order | Exception]: + """Up to twenty orders a request, more in several. The venue checks a + whole batch before sending any of it: one invalid entry fails that + batch, which comes back as that error for each of its requests.""" + results: list[Order | Exception] = [] + bodies: list[dict[str, Any] | Exception] = [] + for request in requests: + try: + bodies.append(translate_order(request, precision=precision)) + except InvalidOrder as exc: + bodies.append(exc) + results = list(bodies) # type: ignore[arg-type] + sendable = [i for i, b in enumerate(bodies) if isinstance(b, dict)] + for start in range(0, len(sendable), BATCH): + chunk = sendable[start:start + BATCH] + try: + raw = await self._call(f"{PREFIX}/order/batch", {"orders": [bodies[i] for i in chunk]}, kind="write") + except ExchangeError as exc: + for index in chunk: + results[index] = exc + continue + answers = list((raw or {}).get("results") or []) + for position, index in enumerate(chunk): + answer = answers[position] if position < len(answers) else {} + if isinstance(answer.get("order"), dict): + results[index] = order_of(answer["order"], account=requests[index].account or self.account, + request=requests[index], body=bodies[index]) # type: ignore[arg-type] + else: + error = map_error(BadRequest(str(answer.get("message") or answer.get("error") or "rejected"), + body=answer, status=200)) + results[index] = error + return results + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one order and read it back, so `filled` shows what matched + before the cancel landed.""" + raw = await self._call(f"{PREFIX}/order/cancel", {"orderId": _order_id(order_id)}, kind="write", + priority=Priority.HIGH) + return await self._after_cancel(order_id, raw or {}, market_id=market_id) + + async def _after_cancel(self, order_id: str, raw: dict[str, Any], *, market_id: str | None) -> Order: + try: + order = await self.fetch_order(order_id) + except OrderNotFound: + return Order( + id=str(order_id), venue=VENUE, account=self.account, + market_id=ids.qualify(VENUE, symbol_of(market_id)) if market_id else "", + side=Side.BUY, type=OrderType.LIMIT, time_in_force=TimeInForce.GTC, + status=OrderStatus.CANCELED, quantity=Decimal("0"), info={"cancel": raw}, + ) + status = order.status if order.status == OrderStatus.CLOSED else OrderStatus.CANCELED + return order.model_copy(update={"status": status, "info": {**order.info, "cancel": raw}}) + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Twenty a request. Each cancelled order is read back; one the venue + refused comes back as its error.""" + results: list[Order | Exception] = [] + for start in range(0, len(order_ids), BATCH): + chunk = order_ids[start:start + BATCH] + raw = await self._call(f"{PREFIX}/order/batch/cancel", {"orderIds": [_order_id(o) for o in chunk]}, + kind="write", priority=Priority.HIGH) + answers = {str(a.get("orderId")): a for a in (raw or {}).get("results") or []} + for order_id in chunk: + answer = answers.get(str(order_id), {}) + if answer.get("error"): + message = f"{VENUE}: {answer.get('message') or answer['error']}" + results.append(OrderNotFound(message, body=answer) if answer["error"] == "OrderNotFound" + else OrderRejected(message, reason=str(answer["error"]), info=answer, body=answer)) + else: + results.append(await self._after_cancel(order_id, answer, market_id=market_id)) + return results + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int: + """Every active order, or every one on a market, cancelled in batches. + Returns how many the venue cancelled.""" + open_orders = await self.fetch_open_orders(market_id=market_id) + cancelled = 0 + ids_ = [o.id for o in open_orders] + for start in range(0, len(ids_), BATCH): + raw = await self._call(f"{PREFIX}/order/batch/cancel", + {"orderIds": [_order_id(o) for o in ids_[start:start + BATCH]]}, + kind="write", priority=Priority.HIGH) + cancelled += sum(1 for a in (raw or {}).get("results") or [] if a.get("result") == "ok") + return cancelled + + async def fetch_order(self, order_id: str) -> Order: + """One order by id. The venue has no single-order route, so the active + orders are searched, then the most recent thousand of the history.""" + wanted = str(order_id) + for order in await self.fetch_open_orders(): + if order.id == wanted: + return order + raw = await self._call(f"{PREFIX}/orders/history", {"limit": HISTORY_PAGE}) + for row in (raw or {}).get("orders") or []: + if str(row.get("orderId")) == wanted: + return order_of(row, account=self.account) + raise OrderNotFound(f"{VENUE}: no order {wanted}") + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + symbol = symbol_of(market_id) if market_id else None + orders: list[Order] = [] + offset = 0 + while True: + raw = await self._call(f"{PREFIX}/orders/active", + {k: v for k, v in {"symbol": symbol, "limit": ACTIVE_PAGE, "offset": offset}.items() + if v is not None}) + rows = (raw or {}).get("orders") or [] + orders += [order_of(r, account=self.account) for r in rows] + if len(rows) < ACTIVE_PAGE: + return orders + offset += len(rows) + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """Orders in the history (`status` `filled` or `cancelled`, the venue's + words), or the active ones (`open`). `since` (ms) reads the window from + then to now, at most `limit` (1000) orders and no cursor; otherwise + the history pages by offset and `next_cursor` continues it.""" + if status == "open": + return Page(await self.fetch_open_orders(market_id=market_id)) + if status not in (None, "filled", "cancelled"): + raise BadRequest(f"{VENUE}: status is open, filled or cancelled, got {status!r}") + wanted = max(1, min(limit or 50, HISTORY_PAGE)) + fields: dict[str, Any] = {"limit": wanted} + if status: + fields["status"] = status + if market_id: + fields["symbol"] = symbol_of(market_id) + offset = 0 + if since is not None: + fields["from"], fields["to"] = int(since), int(time.time() * 1000) + 1 + elif cursor: + if not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + offset = int(cursor) + fields["offset"] = offset + raw = await self._call(f"{PREFIX}/orders/history", fields) + rows = (raw or {}).get("orders") or [] + more = since is None and len(rows) >= wanted + return Page([order_of(r, account=self.account) for r in rows], + next_cursor=str(offset + len(rows)) if more else None) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """One aggregate fill per order that filled, at its average price (the + venue has no fills route; `info["aggregate"]` says so). Individual + executions, with fees and maker/taker, arrive on the account stream.""" + page = await self.fetch_orders(market_id=market_id, since=since, limit=limit, cursor=cursor) + fills = [fill_of_order(order.info, account=self.account) for order in page] + return Page([f for f in fills if f is not None and (order_id is None or f.order_id == str(order_id))], + next_cursor=page.next_cursor) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + target = account or self.account + fields = {"account": target.subaccount} if target.subaccount else None + raw = await self._call("/v1/balances", fields) + return balance_of(raw if isinstance(raw, list) else [], account=target) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """Open positions, one per contract. `event_id` filters on the venue.""" + query = {"eventTicker": ids.native(VENUE, event_id) if event_id else None} + raw = await self._call(f"{PREFIX}/positions", query=query) + positions = positions_of((raw or {}).get("positions") or [], account=self.account) + if market_id: + wanted = ids.qualify(VENUE, symbol_of(market_id)) + positions = [p for p in positions if p.market_id == wanted] + return [p for p in positions if p.contracts > 0 or p.inventory_yes or p.inventory_no] + + async def fetch_settlements( + self, *, market_id: str | None = None, since: int | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Settlement]: + """Settled positions, newest first, paged by offset.""" + wanted = max(1, min(limit or HISTORY_PAGE, HISTORY_PAGE)) + offset = int(cursor) if cursor and cursor.isdigit() else 0 + raw = await self._call(f"{PREFIX}/positions/settled", query={"limit": wanted, "offset": offset or None}) + rows = (raw or {}).get("positions") or [] + out = [settlement_of(r, account=self.account) for r in rows] + if market_id: + wanted_id = ids.qualify(VENUE, symbol_of(market_id)) + out = [s for s in out if s.market_id == wanted_id] + if since is not None: + out = [s for s in out if (s.timestamp or 0) >= since] + return Page(out, next_cursor=str(offset + len(rows)) if len(rows) >= wanted else None) + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The contract's fee schedule (from its listing date, one public event + read) evaluated at `price` and `quantity`. The fee is symmetric in the + YES price, so `side` does not change it.""" + symbol = symbol_of(market_id) + contract = await self._contract(symbol) + schedule = fee_schedule_of(contract, symbol=symbol) + taker = schedule.estimate(float(price), float(quantity), taker=True) + maker = schedule.estimate(float(price), float(quantity), taker=False) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, symbol), side=side, price=D(price), quantity=D(quantity), + taker_fee=D(str(taker)) if taker is not None else None, + maker_fee=D(str(maker)) if maker is not None else None, + currency="USD", info={"schedule": schedule.model_dump()}, + ) + + async def _contract(self, symbol: str) -> dict[str, Any]: + """The public contract record for a symbol, found through its event.""" + for ticker in candidate_event_tickers(symbol): + try: + event = await self._public(f"{PREFIX}/events/{ticker}") or {} + except MarketNotFound: + continue + for group in [event, *(event.get("events") or [])]: + for contract in group.get("contracts") or []: + if native_symbol(str(contract.get("instrumentSymbol") or "")) == symbol: + return contract + raise MarketNotFound(f"{VENUE}: no event lists {symbol}") + + async def close(self) -> None: + await self.http.close() + + +def _order_id(order_id: Any) -> int: + text = str(order_id) + if not text.isdigit(): + raise InvalidOrder(f"{VENUE}: order ids are numbers, got {order_id!r}") + return int(text) + diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..b987970 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -278,6 +278,22 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} +def ask_gemini(p: Prompter) -> dict[str, str]: + p.say("\nGemini Predictions: market data needs no key. Trading needs a Gemini account with Prediction Markets") + p.say("access (US) and an API key from exchange.gemini.com/settings/api: account-scoped, Trader role, and") + p.say("'Uses a time-based nonce' ticked (the WebSocket takes no other kind). The secret is typed hidden.") + key = p.value("API key (account-...)", check=_required("The API key")) + secret = p.value("API secret (typed hidden)", secret=True, + check=lambda raw: "The API secret is required." if not raw else + "That is the API key again; the secret is the other half of the pair." if raw == key else None) + env = p.value("Environment (prod trades real money; sandbox uses api.sandbox.gemini.com)", default="prod", + check=_choice("prod", "sandbox")) + values = {"GEMINI_API_KEY": key, "GEMINI_API_SECRET": secret} + if env != "prod": + values["GEMINI_ENV"] = env + return values + + VENUES: list[tuple[str, tuple[str, ...], Callable[[Prompter], dict[str, str]]]] = [ ("Kalshi", ("KALSHI_KEY_ID",), ask_kalshi), ("Polymarket", ("POLYMARKET_PRIVATE_KEY",), ask_polymarket), @@ -289,6 +305,7 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), + ("Gemini Predictions (market data needs no key)", ("GEMINI_API_KEY",), ask_gemini), ] """Each venue, the settings any of which means it is set up already (an API key alone is, for market data), and the questions.""" diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 509a8fd..130d995 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -13,6 +13,7 @@ market's price ladder, 0.01 when it publishes none Polymarket five shares minimum; tick per market, 0.01 by default Polymarket US whole contracts only; tick per market + Gemini tick per contract (`priceIncrement`), contracts in hundredths """ from __future__ import annotations @@ -66,6 +67,11 @@ # £0.05. "smarkets": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.0001"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("0.05")}, + # Each contract publishes its grid (priceIncrement, quantityIncrement, + # quantityMinimum); every one read so far is a cent and a hundredth of a + # contract, which is the default here. The tick comes from the market. + "gemini": {"default_tick": Decimal("0.01"), "min_quantity": Decimal("0.01"), "quantity_step": Decimal("0.01"), + "whole": False}, } diff --git a/synpath/types.py b/synpath/types.py index 3233e1d..8d6ba4a 100644 --- a/synpath/types.py +++ b/synpath/types.py @@ -559,14 +559,17 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo Kalshi schedule) the fee for the order is rounded up to the cent, as the venue charges it. Kalshi's `flat` type follows a separate table this library does not have, so it returns `None`. - * `quadratic_theta` (Polymarket US, Polymarket) — `theta * C * + * `quadratic_theta` (Polymarket US, Polymarket, Gemini) — `theta * C * (P * (1 - P)) ** exponent`, the same shape with the coefficient published directly: `taker_rate` and `maker_rate` hold the venue's thetas, and a negative maker theta is a rebate, returned here as a negative fee. `exponent` is 1 unless the venue says otherwise. - * `min_price` (predict.fun) — `rate * min(P, 1 - P) * C` for a - taker, on the cheaper side of the market; a maker pays `maker_rate` - (zero on every market read so far). + Gemini's rates are 0.07 and 0.0175, rounded up to the cent + (`rounding="up_to_cent"`). + * `min_price` (predict.fun, Gemini's legacy schedule) — `rate * + min(P, 1 - P) * C` for a taker, on the cheaper side of the market; + a maker pays `maker_rate` (zero on every predict.fun market read so + far). Rounded up to the cent where `rounding="up_to_cent"`. * `hyperliquid_outcome` (Hyperliquid) — `rate * P * C`, the notional of the fill, charged only when the fill closes a position (and at settlement); a fill that opens one pays nothing. The estimate is the @@ -611,7 +614,7 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo fee = max(fee, self.min_fee) return round(fee, 6) if self.fee_type == "min_price" and rate is not None: - return round(rate * min(price, 1 - price) * contracts, 6) + return self._rounded(rate * min(price, 1 - price) * contracts) if self.fee_type == "hyperliquid_outcome" and rate is not None: return round(rate * price * contracts, 6) if self.fee_type == "limitless_curve" and rate is not None: @@ -622,9 +625,17 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo return round(rate * (1 - price) * contracts, 6) if self.fee_type == "quadratic_theta" and rate is not None: power = 1.0 if self.exponent is None else self.exponent - return round(rate * contracts * (price * (1 - price)) ** power, 6) + return self._rounded(rate * contracts * (price * (1 - price)) ** power) return None + def _rounded(self, fee: float) -> float: + """A fee as the venue charges it: up to the cent where `rounding` is + `up_to_cent` (a rebate, negative, rounds toward zero), else to six + decimals.""" + if self.rounding == "up_to_cent": + fee = math.ceil(round(fee, 9) * 100 - 1e-9) / 100 + return round(fee, 6) + LIMITLESS_FEE_CEILING = 0.03 LIMITLESS_BUY_CURVE: tuple[tuple[float, float], ...] = ( diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index 6260aee..55360ad 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -56,6 +56,9 @@ def __getattr__(name: str): if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream + if name in ("GeminiMarketStream", "GeminiUserStream"): + from . import gemini + return getattr(gemini, name) if name in ("HyperliquidMarketStream", "HyperliquidUserStream"): from . import hyperliquid return getattr(hyperliquid, name) @@ -75,5 +78,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "GeminiMarketStream", "GeminiUserStream", ] diff --git a/synpath/ws/gemini.py b/synpath/ws/gemini.py new file mode 100644 index 0000000..a10b0cd --- /dev/null +++ b/synpath/ws/gemini.py @@ -0,0 +1,540 @@ +"""Gemini Predictions' WebSocket: books, prints, quotes and contract status, +and an account's orders, fills, positions and balances. + +One endpoint, `wss://ws.gemini.com`. Market data is public and needs no key. +The account streams authenticate on the handshake with an account-scoped, +time-based API key (`X-GEMINI-APIKEY`, `-NONCE`, `-PAYLOAD`, `-SIGNATURE`); +nothing can authenticate a connection after it opens. Requests are +`{"id", "method": "SUBSCRIBE", "params": [stream names]}`, answered +`{"id", "status": 200}` or with an `error`. Gemini sends symbols lower case; +they are read back upper case, as REST spells them. + +**Books are differential, in YES space.** `{symbol}@depth@100ms` sends the +levels that changed every 100 ms. The market stream connects with +`snapshot=-1`, so the first frame after subscribing is the whole book. Frames +chain by update id: a frame's first id `U` is the last id `u` of the frame +before it (ids are the exchange's, shared across books). A frame whose `U` +is past the last applied `u` is a gap: the book is dropped and the stream +subscribed again for a fresh snapshot. A NO view is the YES book mirrored. + +**Quotes and prints.** `@bookTicker` is the top of book with the last trade +price once there is one; `@trade` the public tape, `m` saying whether the +buyer was the maker, so the taker sold when it is true. + +**Account.** `orders@account` reports each order's transitions; a frame +carries only what changed, so the stream keeps each order's last known +fields. Fills ride on `FILLED` and `PARTIALLY_FILLED` frames (`Z` the +execution's quantity, `L` its price, `n` its fee). Nothing is replayed after +a reconnect, so one is reported with `reconcile_required`. +""" +from __future__ import annotations + +from collections import OrderedDict +from decimal import Decimal +from typing import Any + +from .. import ids +from ..base import Capability +from ..gemini import native_symbol +from ..trading.credentials import GeminiCredentials +from ..trading.gemini import WS_STATUS, GeminiSigner, yes_leg, yes_price +from ..trading.types import ( + Account, Balance, Fill, Liquidity, Order, OrderStatus, OrderType, Position, PositionSide, Side, TimeInForce, +) +from .base import ( + BalanceEvent, BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, + QuoteEvent, Stream, TradeEvent, maybe_D, +) + +VENUE = "gemini" +WS_URL = "wss://ws.gemini.com" +SANDBOX_WS_URL = "wss://ws.sandbox.gemini.com" + +DEPTH = "@depth@100ms" + +CONTRACT_STATE = { + "awaiting approval": "created", + "approved": "created", + "active": "open", + "closed": "closed", + "under review": "closed", + "under_review": "closed", + "settled": "settled", + "invalid": "settled", +} +"""A contract's new status as the shared market state.""" + +ORDER_TYPE = {"LIMIT": OrderType.LIMIT, "MARKET": OrderType.MARKET, "STOP_LIMIT": OrderType.STOP_LIMIT, + "STOP_MARKET": OrderType.STOP_MARKET} + +ORDER_MEMORY = 4096 +"""Orders whose last fields the account stream keeps, most recent first.""" + + +def ns_to_ms(value: Any) -> int | None: + """Gemini stamps stream events in nanoseconds (contract status in ms).""" + if value in (None, ""): + return None + stamp = int(value) + return stamp // 1_000_000 if stamp > 10**15 else stamp + + +def symbol_of(market_id: str) -> str: + return native_symbol(ids.native(VENUE, market_id)) + + +def levels_of(rows: Any) -> list[tuple[Decimal, Decimal]]: + out = [] + for row in rows or []: + if isinstance(row, (list, tuple)) and len(row) >= 2: + price, size = maybe_D(row[0]), maybe_D(row[1]) + if price is not None and size is not None: + out.append((price, size)) + return out + + +def outcome_word(value: Any) -> str: + """`YES` / `NO`, or the booleans the AsyncAPI document uses, as `yes` / `no`.""" + if isinstance(value, bool): + return "yes" if value else "no" + return "no" if str(value).strip().lower() in ("no", "false") else "yes" + + +class _GeminiStream(Stream): + """Subscriptions by stream name, sent again on every connect.""" + + venue = VENUE + + def __init__(self, url: str, **kwargs: Any): + super().__init__(url, **kwargs) + self.streams: dict[str, None] = {} + self._next_id = 1 + + def _allocate(self) -> int: + number = self._next_id + self._next_id += 1 + return number + + async def _subscribe(self, names: list[str]) -> None: + new = [name for name in names if name not in self.streams] + for name in new: + self.streams[name] = None + if new: + await self.send({"id": self._allocate(), "method": "SUBSCRIBE", "params": new}) + + async def _unsubscribe(self, names: list[str]) -> None: + gone = [name for name in names if self.streams.pop(name, 0) is None] + if gone: + await self.send({"id": self._allocate(), "method": "UNSUBSCRIBE", "params": gone}) + + async def on_connect(self) -> None: + if self.streams: + await self.send({"id": self._allocate(), "method": "SUBSCRIBE", "params": list(self.streams)}) + + def _reply(self, message: dict[str, Any]) -> list[Event] | None: + """A request's answer; `None` for anything else.""" + if "id" not in message or "status" not in message or "e" in message: + return None + if message.get("status") == 200: + self.status("subscribed", str(message.get("id")), key=str(message.get("id"))) + return [] + error = message.get("error") or {} + self.status("error", f"venue: {error.get('code')} {error.get('msg')}".strip(), key=str(message.get("id"))) + return [] + + +class GeminiMarketStream(_GeminiStream): + """Books, prints, quotes and contract status. No key. + + ```python + async with GeminiMarketStream() as stream: + await stream.watch_order_book(["gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL"]) + async for event in stream: + ... + ``` + """ + + name = "market" + has: dict[str, Capability] = { + "watch_order_book": True, + "watch_trades": True, + "watch_ticker": True, + "watch_market_status": True, + } + + def __init__(self, *, sandbox: bool = False, url: str | None = None, **kwargs: Any): + base = url or (SANDBOX_WS_URL if sandbox else WS_URL) + super().__init__(base if "snapshot=" in base else f"{base}{'&' if '?' in base else '?'}snapshot=-1", **kwargs) + self.books: dict[str, LocalBook] = {} + """Instrument symbol -> its YES book.""" + self._awaiting: set[str] = set() + """Symbols whose next depth frame is a snapshot.""" + self._last: dict[str, int] = {} + + async def watch_order_book(self, market_ids: list[str]) -> None: + """Each contract's whole book, then its changes.""" + names = [] + for market_id in market_ids: + symbol = symbol_of(market_id) + if symbol not in self.books: + self.books[symbol] = LocalBook() + self._awaiting.add(symbol) + names.append(f"{symbol}{DEPTH}") + await self._subscribe(names) + + async def watch_trades(self, market_ids: list[str]) -> None: + """Public prints from now on, in the YES price.""" + await self._subscribe([f"{symbol_of(m)}@trade" for m in market_ids]) + + async def watch_ticker(self, market_ids: list[str]) -> None: + """Top of book as it changes, with the last trade price.""" + await self._subscribe([f"{symbol_of(m)}@bookTicker" for m in market_ids]) + + async def watch_market_status(self) -> None: + """Every contract's status changes, and strikes as they are set.""" + await self._subscribe(["contractStatus"]) + + async def unwatch(self, market_ids: list[str]) -> None: + for market_id in market_ids: + symbol = symbol_of(market_id) + await self._unsubscribe([f"{symbol}{DEPTH}", f"{symbol}@trade", f"{symbol}@bookTicker"]) + self.books.pop(symbol, None) + self._awaiting.discard(symbol) + self._last.pop(symbol, None) + + def book(self, market_id: str, side: str = "yes") -> LocalBook | None: + """The local book on either side; `None` if not watched.""" + book = self.books.get(symbol_of(market_id)) + if book is None: + return None + return book if side != "no" else book.mirrored() + + async def on_connect(self) -> None: + # Every depth subscription answers a reconnect with a snapshot. + self._awaiting = set(self.books) + self._last.clear() + await super().on_connect() + + def on_disconnect(self) -> None: + for book in self.books.values(): + book.invalidate() + + # -- reading -------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + reply = self._reply(message) + if reply is not None: + return reply + kind = message.get("e") + if kind == "depthUpdate": + return self._on_depth(message) + if kind == "contractStatus": + return [self._status_event(message)] + if kind is None and "t" in message and "p" in message and "q" in message: + return [self._trade(message)] + if kind is None and isinstance(message.get("b"), str) and "s" in message: + return [self._quote(message)] + return [] + + def _on_depth(self, message: dict[str, Any]) -> list[Event]: + symbol = native_symbol(str(message.get("s") or "")) + book = self.books.get(symbol) + if book is None: + return [] + market_id = ids.qualify(VENUE, symbol) + first, last = message.get("U"), message.get("u") + stamp = ns_to_ms(message.get("E")) + bids, asks = levels_of(message.get("b")), levels_of(message.get("a")) + if symbol in self._awaiting: + recovering = book.sequence is not None + book.replace(bids, asks) + book.sequence, book.timestamp = last, stamp + self._awaiting.discard(symbol) + self._last[symbol] = int(last) if last is not None else 0 + if recovering: + self.status("resynced", f"book {market_id}", key=market_id) + levels_bid, levels_ask = book.levels() + return [BookEvent(venue=VENUE, market_id=market_id, kind="snapshot", bids=levels_bid, asks=levels_ask, + best_bid=book.best_bid, best_ask=book.best_ask, sequence=last, timestamp=stamp)] + if not book.ready: + return [] + previous = self._last.get(symbol) + if previous is not None and last is not None and int(last) <= previous: + return [] # already applied + if previous is not None and first is not None and int(first) > previous: + self.status("gap", f"book {market_id}: update {first} after {previous}", key=market_id) + book.invalidate() + self._awaiting.add(symbol) + self._later(self._resubscribe(f"{symbol}{DEPTH}")) + return [] + changed_bids = tuple(BookLevel(p, book.set("bid", p, s)) for p, s in bids) + changed_asks = tuple(BookLevel(p, book.set("ask", p, s)) for p, s in asks) + book.sequence, book.timestamp = last, stamp + if last is not None: + self._last[symbol] = int(last) + return [BookEvent(venue=VENUE, market_id=market_id, kind="delta", bids=changed_bids, asks=changed_asks, + best_bid=book.best_bid, best_ask=book.best_ask, sequence=last, timestamp=stamp)] + + async def _resubscribe(self, name: str) -> None: + self.streams.pop(name, None) + await self.send({"id": self._allocate(), "method": "UNSUBSCRIBE", "params": [name]}) + await self._subscribe([name]) + + def _later(self, coroutine: Any) -> None: + import asyncio + + asyncio.get_running_loop().create_task(coroutine) + + def _trade(self, message: dict[str, Any]) -> TradeEvent: + symbol = native_symbol(str(message.get("s") or "")) + maker_bought = message.get("m") + return TradeEvent( + venue=VENUE, market_id=ids.qualify(VENUE, symbol), id=str(message.get("t") or "") or None, + price=Decimal(str(message["p"])), quantity=Decimal(str(message["q"])), + taker_side=None if maker_bought is None else (Side.SELL if maker_bought else Side.BUY), + timestamp=ns_to_ms(message.get("E")), info=message, + ) + + def _quote(self, message: dict[str, Any]) -> QuoteEvent: + symbol = native_symbol(str(message.get("s") or "")) + return QuoteEvent( + venue=VENUE, market_id=ids.qualify(VENUE, symbol), + bid=maybe_D(message.get("b")) or None, ask=maybe_D(message.get("a")) or None, + bid_size=maybe_D(message.get("B")), ask_size=maybe_D(message.get("A")), + last=maybe_D(message.get("c")), timestamp=ns_to_ms(message.get("E")), info=message, + ) + + def _status_event(self, message: dict[str, Any]) -> MarketStatusEvent: + symbol = native_symbol(str(message.get("s") or "")) + new = str(message.get("n") or "") + old = str(message.get("o") or "") + state = CONTRACT_STATE.get(new.lower(), "updated") if new.lower() != old.lower() else "updated" + return MarketStatusEvent( + venue=VENUE, market_id=ids.qualify(VENUE, symbol), state=state, # type: ignore[arg-type] + native=new or None, timestamp=ns_to_ms(message.get("E")), info=message, + ) + + +class GeminiUserStream(_GeminiStream): + """This account's orders, fills, positions and balances. + + Needs an account-scoped API key with a time-based nonce (Auditor is + enough to read positions; the stream sends no orders). + + ```python + async with GeminiUserStream(creds) as stream: + await stream.watch_orders() + await stream.watch_positions() + async for event in stream: + ... + ``` + """ + + name = "account" + private = True + has: dict[str, Capability] = { + "watch_orders": True, + "watch_my_trades": True, + "watch_positions": True, + "watch_balance": True, + } + + def __init__(self, credentials: GeminiCredentials, *, url: str | None = None, account_name: str = "default", + signer: GeminiSigner | None = None, **kwargs: Any): + super().__init__(url or (SANDBOX_WS_URL if credentials.env == "sandbox" else WS_URL), **kwargs) + self.signer = signer or GeminiSigner(credentials.api_key, credentials.api_secret) + self.account = Account(venue=VENUE, name=account_name) + self.only: set[str] | None = None + self._orders: OrderedDict[str, dict[str, Any]] = OrderedDict() + + def headers(self) -> dict[str, str]: + return self.signer.ws_headers() + + async def watch_orders(self, market_ids: list[str] | None = None) -> None: + """This account's orders on every contract, or only these.""" + self._narrow(market_ids) + await self._subscribe(["orders@account"]) + + async def watch_my_trades(self, market_ids: list[str] | None = None) -> None: + """This account's executions, read off its order events.""" + self._narrow(market_ids) + await self._subscribe(["orders@account"]) + + async def watch_positions(self, market_ids: list[str] | None = None) -> None: + """Position changes, settlement payouts included. The first frame is + the open positions.""" + self._narrow(market_ids) + await self._subscribe(["positions@account"]) + + async def watch_balance(self) -> None: + await self._subscribe(["balances@account"]) + + def _narrow(self, market_ids: list[str] | None) -> None: + if market_ids is None: + self.only = None + elif self.only is not None or not self.streams: + self.only = (self.only or set()) | {ids.qualify(VENUE, symbol_of(m)) for m in market_ids} + + 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]: + if not isinstance(message, dict): + return [] + reply = self._reply(message) + if reply is not None: + return reply + kind = message.get("e") + if kind == "orderUpdate": + return self._on_order(message) + if kind == "positionReport": + return self._on_positions(message) + if kind == "balanceUpdate": + return self._on_balances(message) + return [] + + def _remember(self, frame: dict[str, Any]) -> dict[str, Any]: + """The order's fields so far, this frame's over the last ones.""" + key = str(frame.get("i") or "") + merged = {**self._orders.get(key, {}), **{k: v for k, v in frame.items() if v not in (None, "")}} + self._orders[key] = merged + self._orders.move_to_end(key) + while len(self._orders) > ORDER_MEMORY: + self._orders.popitem(last=False) + return merged + + def _on_order(self, frame: dict[str, Any]) -> list[Event]: + merged = self._remember(frame) + order = order_of_frame(merged, frame, account=self.account) + if not self._wanted(order.market_id): + return [] + events: list[Event] = [OrderEvent(venue=VENUE, order=order, native=str(frame.get("X") or ""))] + fill = fill_of_frame(merged, frame, account=self.account) + if fill is not None: + events.append(FillEvent(venue=VENUE, fill=fill)) + return events + + def _on_positions(self, frame: dict[str, Any]) -> list[Event]: + stamp = ns_to_ms(frame.get("E")) + events: list[Event] = [] + for row in frame.get("P") or []: + if row.get("t") not in (None, "ec"): + continue + position = position_of_row(row, account=self.account, timestamp=stamp) + if self._wanted(position.market_id): + events.append(PositionEvent(venue=VENUE, position=position)) + return events + + def _on_balances(self, frame: dict[str, Any]) -> list[Event]: + stamp = ns_to_ms(frame.get("E")) + events: list[Event] = [] + for row in frame.get("B") or []: + confirmed, available = maybe_D(row.get("c")), maybe_D(row.get("f")) + if confirmed is None and available is None: + continue + total = confirmed if confirmed is not None else available + free = available if available is not None else confirmed + assert total is not None and free is not None + events.append(BalanceEvent(venue=VENUE, balance=Balance( + venue=VENUE, account=self.account, currency=str(row.get("a") or "USD").upper(), + total=total, available=free, locked=total - free, timestamp=stamp, info=row, + ))) + return events + + +def order_of_frame(merged: dict[str, Any], frame: dict[str, Any], *, account: Account | None = None) -> Order: + """An `orderUpdate` (with the order's earlier fields merged in) on the YES leg.""" + outcome = outcome_word(merged.get("O", "YES")) + venue_side = str(merged.get("S") or "BUY") + status_word = str(frame.get("X") or merged.get("X") or "").upper() + quantity = maybe_D(merged.get("q")) + remaining = maybe_D(merged.get("z")) + executed = maybe_D(frame.get("Z")) + if quantity is not None and remaining is not None and status_word in ("FILLED", "PARTIALLY_FILLED", "OPEN", "NEW"): + filled = quantity - remaining + elif status_word == "CANCELED" and executed is not None: + filled = executed # cumulative on a cancel + elif quantity is not None and remaining is not None: + filled = quantity - remaining + else: + filled = Decimal("0") + if quantity is None: + quantity = filled + (remaining or Decimal("0")) + status = WS_STATUS.get(status_word, OrderStatus.OPEN) + if status == OrderStatus.CANCELED: + remaining = Decimal("0") + stop = maybe_D(merged.get("P")) + first = ns_to_ms(merged.get("_created") or merged.get("T")) + merged.setdefault("_created", merged.get("T")) + return Order( + id=str(merged.get("i") or ""), + client_order_id=merged.get("c") or None, + venue=VENUE, + account=account, + market_id=ids.qualify(VENUE, native_symbol(str(merged.get("s") or ""))), + side=yes_leg(venue_side, outcome), + type=ORDER_TYPE.get(str(merged.get("o") or "LIMIT").upper(), OrderType.LIMIT), + time_in_force=TimeInForce.GTC, + status=status, + price=yes_price(merged.get("p"), outcome), + stop_price=yes_price(stop, outcome) if stop else None, + quantity=quantity, + filled=filled, + remaining=remaining if remaining is not None else quantity - filled, + average_price=yes_price(merged.get("a"), outcome) if merged.get("a") not in (None, "", "0") else None, + fee_currency="USD", + last_fill_price=yes_price(frame.get("L"), outcome) if frame.get("L") else None, + last_fill_quantity=executed if status_word in ("FILLED", "PARTIALLY_FILLED") else None, + reduce_only=venue_side.upper() == "SELL", + created_at=first, + updated_at=ns_to_ms(frame.get("T") or frame.get("E")), + info={**frame, "venue_side": venue_side.lower(), "venue_outcome": outcome, + "rejection_reason": frame.get("r")}, + ) + + +def fill_of_frame(merged: dict[str, Any], frame: dict[str, Any], *, account: Account | None = None) -> Fill | None: + """The execution on a `FILLED` or `PARTIALLY_FILLED` frame, else `None`.""" + if str(frame.get("X") or "").upper() not in ("FILLED", "PARTIALLY_FILLED"): + return None + quantity, price = maybe_D(frame.get("Z")), frame.get("L") + if quantity is None or quantity <= 0 or price in (None, ""): + return None + outcome = outcome_word(merged.get("O", "YES")) + maker = frame.get("m") + fee = maybe_D(frame.get("n")) + return Fill( + id=str(frame.get("t") or f"{merged.get('i')}-{frame.get('T')}"), + order_id=str(merged.get("i") or ""), + client_order_id=merged.get("c") or None, + venue=VENUE, account=account, + market_id=ids.qualify(VENUE, native_symbol(str(merged.get("s") or ""))), + side=yes_leg(str(merged.get("S") or "BUY"), outcome), + price=yes_price(price, outcome), # type: ignore[arg-type] + quantity=quantity, + fee=fee, fee_currency="USD", + liquidity=Liquidity.UNKNOWN if maker is None else (Liquidity.MAKER if maker else Liquidity.TAKER), + timestamp=ns_to_ms(frame.get("T") or frame.get("E")) or 0, + info=frame, + ) + + +def position_of_row(row: dict[str, Any], *, account: Account | None = None, timestamp: int | None = None) -> Position: + """One `positionReport` row: a signed position (positive holds YES) and, + on a settlement close, the payout and the outcome.""" + amounts = {str(a.get("t")): a for a in row.get("a") or [] if isinstance(a, dict)} + signed = maybe_D((amounts.get("position") or {}).get("v")) or Decimal("0") + payout_row = amounts.get("settlement_payout") + payout = maybe_D(payout_row.get("v")) if payout_row else None + result = str(payout_row.get("o") or "").lower() if payout_row else None + return Position( + venue=VENUE, account=account, market_id=ids.qualify(VENUE, native_symbol(str(row.get("s") or ""))), + side=PositionSide.LONG if signed > 0 else PositionSide.SHORT if signed < 0 else PositionSide.FLAT, + contracts=abs(signed), + resolved=payout_row is not None, final=payout_row is not None, + won=(payout > 0) if payout is not None else None, + payout=payout, timestamp=timestamp, + info={**row, "result": result if result in ("yes", "no") else None}, + ) diff --git a/tests/samples/gemini_book.json b/tests/samples/gemini_book.json new file mode 100644 index 0000000..117bdae --- /dev/null +++ b/tests/samples/gemini_book.json @@ -0,0 +1,31 @@ +{ + "bids": [ + { + "price": "0.52", + "amount": "5000.0", + "timestamp": "1792773399" + }, + { + "price": "0.51", + "amount": "1470.0", + "timestamp": "1792773399" + }, + { + "price": "0.48", + "amount": "50.0", + "timestamp": "1792773399" + } + ], + "asks": [ + { + "price": "0.53", + "amount": "8050.0", + "timestamp": "1792773399" + }, + { + "price": "0.54", + "amount": "1880.0", + "timestamp": "1792773399" + } + ] +} \ No newline at end of file diff --git a/tests/samples/gemini_candles.json b/tests/samples/gemini_candles.json new file mode 100644 index 0000000..acbbeee --- /dev/null +++ b/tests/samples/gemini_candles.json @@ -0,0 +1,50 @@ +[ + [ + 1792767600000, + 0.52, + 0.52, + 0.52, + 0.52, + 0 + ], + [ + 1792764000000, + 0.52, + 0.52, + 0.52, + 0.52, + 0 + ], + [ + 1792760400000, + 0.51, + 0.52, + 0.51, + 0.52, + 76.23 + ], + [ + 1792756800000, + 0.51, + 0.51, + 0.51, + 0.51, + 0 + ], + [ + 1792753200000, + 0.5, + 0.51, + 0.5, + 0.51, + 3.78 + ], + [ + 1792749600000, + 0.5, + 0.5, + 0.5, + 0.5, + 0 + ] +] \ No newline at end of file diff --git a/tests/samples/gemini_categories.json b/tests/samples/gemini_categories.json new file mode 100644 index 0000000..30e9e76 --- /dev/null +++ b/tests/samples/gemini_categories.json @@ -0,0 +1,18 @@ +{ + "categories": [ + "Live", + "Sports", + "Pro Baseball", + "Midterms", + "College Football", + "Pro Football", + "Crypto", + "Commodities", + "Economics", + "Politics", + "Tech", + "Business", + "Weather", + "Culture" + ] +} \ No newline at end of file diff --git a/tests/samples/gemini_crypto_event.json b/tests/samples/gemini_crypto_event.json new file mode 100644 index 0000000..395c876 --- /dev/null +++ b/tests/samples/gemini_crypto_event.json @@ -0,0 +1,108 @@ +{ + "id": "376946", + "title": "BTC price today at 12:40pm EDT", + "slug": "btc-price-today-at-1240pm-edt", + "type": "binary", + "category": "Crypto", + "ticker": "BTC05M2610231640", + "status": "active", + "createdAt": "2026-10-23T15:30:30.656Z", + "contracts": [ + { + "id": "376946-880691", + "label": "Up", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "If the price of Bitcoin (BTC) is greater than or equal to the price specified in the title on October 23, 2026 at 12:40pm ET, this market will resolve to Yes. Otherwise, it will resolve to No. If no eligible trade occurs during the contract window, this contract will resolve based on the most recent traded price prior. Outcome verified against the LS-LRRR-XBTUSD index. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/btc_398860b3-ea2f-47a7-b167-a8c6ad03d6d7.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.84", + "no": "0.2" + }, + "sell": { + "yes": "0.8", + "no": "0.16" + }, + "bestBid": "0.8", + "bestAsk": "0.84", + "lastTradePrice": "0.56" + }, + "status": "active", + "createdAt": "2026-10-23T15:30:30.656Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/btc_398860b3-ea2f-47a7-b167-a8c6ad03d6d7.pdf", + "ticker": "UP", + "instrumentSymbol": "GEMI-BTC05M2610231640-UP", + "marketState": "open", + "abbreviatedName": "Up", + "color": "#4CAF50", + "expiryDate": "2026-10-23T16:40:00.000Z", + "effectiveDate": "2026-10-23T16:35:00.000Z", + "sortOrder": 0, + "strike": { + "type": "reference", + "value": "81624.71", + "availableAt": "2026-10-23T16:35:00.000Z" + }, + "source": "LS-LRRR-XBTUSD", + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-23T15:30:30.656Z", + "isLive": true, + "participants": [], + "description": "Interval BTC price prediction event expiring on October 23, 2026 at 12:40pm ET", + "imageUrl": "https://assets.gemini.com/predictions/images/btc_ace6c0f4-f500-4b7d-a66c-a3ea5ea1d9e9.avif", + "series": "BTC05M", + "volume": "0", + "volume24h": "0", + "tags": [ + "Bitcoin" + ], + "expiryDate": "2026-10-23T16:40:00.000Z", + "startTime": "2026-10-23T16:35:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/btc_398860b3-ea2f-47a7-b167-a8c6ad03d6d7.pdf", + "subcategory": { + "id": 32, + "slug": "crypto_bitcoin", + "name": "Bitcoin", + "path": [ + "Crypto", + "Bitcoin" + ] + }, + "socialImageUrl": "https://assets.gemini.com/predictions/images/btc_29510bf2-83f3-4d5e-beec-2d09f473347c.png", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Top_10_-_Bitcoin_1_23ea2ded-f899-4bbd-955d-355794d5ef0d.avif", + "template": "crypto-up-down", + "eventTags": [ + { + "id": 11, + "name": "Five Minute", + "slug": "five-minute" + }, + { + "id": 19, + "name": "Up Down", + "slug": "up-down" + } + ], + "source": "LS-LRRR-XBTUSD", + "sourceDetails": { + "agency": "lukka", + "index": "LS-LRRR-XBTUSD" + }, + "volumeDelta24hPct": "0" +} \ No newline at end of file diff --git a/tests/samples/gemini_event.json b/tests/samples/gemini_event.json new file mode 100644 index 0000000..6c05e7d --- /dev/null +++ b/tests/samples/gemini_event.json @@ -0,0 +1,649 @@ +{ + "id": "366148", + "title": "Chicago vs Milwaukee", + "slug": "chicago-vs-milwaukee", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232000-CHI-MIL-M", + "status": "active", + "createdAt": "2026-10-20T00:00:47.320Z", + "contracts": [ + { + "id": "366148-858483", + "label": "Milwaukee", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "If Milwaukee wins the Pro Basketball game between Milwaukee and Chicago originally scheduled for October 23, 2026 at 4:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.53", + "no": "0.48" + }, + "sell": { + "yes": "0.52", + "no": "0.47" + }, + "bestBid": "0.52", + "bestAsk": "0.53", + "lastTradePrice": "0.52" + }, + "status": "active", + "createdAt": "2026-10-20T00:00:47.320Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "ticker": "MIL", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-M-MIL", + "marketState": "open", + "abbreviatedName": "MIL", + "color": "#00471B", + "imageUrl": "https://assets.gemini.com/predictions/images/MIL_5d06f35a-b234-4ee0-b615-ae4eb0452046.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 2, + "sportsSide": "home", + "teamId": "583ecefd-fb46-11e1-82cb-f4ce4684ea4c", + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "366148-858468", + "label": "Chicago", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "If Chicago wins the Pro Basketball game between Chicago and Milwaukee originally scheduled for October 23, 2026 at 4:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.48", + "no": "0.53" + }, + "sell": { + "yes": "0.47", + "no": "0.52" + }, + "bestBid": "0.47", + "bestAsk": "0.48", + "lastTradePrice": "0.49" + }, + "status": "active", + "createdAt": "2026-10-20T00:00:47.320Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "ticker": "CHI", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-M-CHI", + "marketState": "open", + "abbreviatedName": "CHI", + "color": "#CE1141", + "imageUrl": "https://assets.gemini.com/predictions/images/CHI_40120e92-39c2-4920-9ba3-5723e95ce866.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 1, + "sportsSide": "away", + "teamId": "583ec5fd-fb46-11e1-82cb-f4ce4684ea4c", + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-20T00:00:47.320Z", + "isLive": true, + "participants": [ + { + "id": "467", + "name": "Milwaukee Bucks", + "abbreviation": "MIL", + "side": "home" + }, + { + "id": "462", + "name": "Chicago Bulls", + "abbreviation": "CHI", + "side": "away" + } + ], + "description": "Chicago vs Milwaukee", + "imageUrl": "https://assets.gemini.com/predictions/images/bb_f53a7e9b-c0fc-4a5c-a011-d0bf655f8263.avif", + "volume": "1450.6", + "volume24h": "1087.56", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T08:00:00.000Z", + "startTime": "2026-10-23T20:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "https://assets.gemini.com/predictions/images/Card_Carousel__40__b3388226-c9ca-43be-9611-88185341baf0.png", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Card_Carousel__40__b3388226-c9ca-43be-9611-88185341baf0.avif", + "events": [ + { + "id": "372825", + "title": "Chicago vs Milwaukee: Point Spread", + "slug": "chicago-vs-milwaukee-point-spread", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232000-CHI-MIL-S", + "status": "active", + "createdAt": "2026-10-22T05:26:00.779Z", + "contracts": [ + { + "id": "372825-873041", + "label": "Milwaukee wins by more than 1.5 points", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if the score differential recorded in favor of Milwaukee in the entire NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT is above 1.5. Otherwise, it resolves to No. The Underlying is the score differential (Milwaukee's score minus Chicago's score) for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.5", + "no": "0.51" + }, + "sell": { + "yes": "0.49", + "no": "0.5" + }, + "bestBid": "0.49", + "bestAsk": "0.5", + "lastTradePrice": "0.48" + }, + "status": "active", + "createdAt": "2026-10-22T05:26:00.779Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "ticker": "MIL1", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-S-MIL1", + "marketState": "open", + "abbreviatedName": "Milwaukee", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/MIL_5d06f35a-b234-4ee0-b615-ae4eb0452046.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 29, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "372825-873040", + "label": "Milwaukee wins by more than 2.5 points", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if the score differential recorded in favor of Milwaukee in the entire NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT is above 2.5. Otherwise, it resolves to No. The Underlying is the score differential (Milwaukee's score minus Chicago's score) for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.47", + "no": "0.55" + }, + "sell": { + "yes": "0.45", + "no": "0.53" + }, + "bestBid": "0.45", + "bestAsk": "0.47" + }, + "status": "active", + "createdAt": "2026-10-22T05:26:00.779Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "ticker": "MIL2", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-S-MIL2", + "marketState": "open", + "abbreviatedName": "Milwaukee", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/MIL_5d06f35a-b234-4ee0-b615-ae4eb0452046.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 28, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-22T05:26:00.779Z", + "isLive": true, + "participants": [ + { + "id": "467", + "name": "Milwaukee Bucks", + "abbreviation": "MIL", + "side": "home" + }, + { + "id": "462", + "name": "Chicago Bulls", + "abbreviation": "CHI", + "side": "away" + } + ], + "description": "What will the point difference be?", + "imageUrl": "", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T08:00:00.000Z", + "startTime": "2026-10-23T20:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Basketball_6c686009-b224-4e06-8686-f5ddafc52ba8.avif", + "template": "sports-spread", + "gameId": "7e8fc356-9548-4496-ae63-d791e057b337", + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "sportsMarket": { + "sport": "basketball", + "type": "spread", + "subject": "team", + "scope": { + "type": "full_contest" + }, + "metric": "points" + } + }, + { + "id": "372882", + "title": "Chicago vs Milwaukee: Total Points", + "slug": "chicago-vs-milwaukee-total-points", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232000-CHI-MIL-T", + "status": "active", + "createdAt": "2026-10-22T05:41:32.999Z", + "contracts": [ + { + "id": "372882-873353", + "label": "More than 210.5 points scored", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if Chicago and Milwaukee collectively score over 210.5 total points in the NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT. Otherwise, it resolves to No. The Underlying is the total points scored by both teams for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.99", + "no": "0.06" + }, + "sell": { + "yes": "0.94", + "no": "0.01" + }, + "bestBid": "0.94", + "bestAsk": "0.99" + }, + "status": "active", + "createdAt": "2026-10-22T05:41:32.999Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "ticker": "O210", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-T-O210", + "marketState": "open", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/Basketball_48baed80-84ab-4004-aba2-ed1eb5c4e2c9.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 12, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "372882-873354", + "label": "More than 212.5 points scored", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if Chicago and Milwaukee collectively score over 212.5 total points in the NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT. Otherwise, it resolves to No. The Underlying is the total points scored by both teams for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.98", + "no": "0.08" + }, + "sell": { + "yes": "0.92", + "no": "0.02" + }, + "bestBid": "0.92", + "bestAsk": "0.98" + }, + "status": "active", + "createdAt": "2026-10-22T05:41:32.999Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "ticker": "O212", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-T-O212", + "marketState": "open", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/Basketball_48baed80-84ab-4004-aba2-ed1eb5c4e2c9.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 13, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-22T05:41:32.999Z", + "isLive": true, + "participants": [ + { + "id": "467", + "name": "Milwaukee Bucks", + "abbreviation": "MIL", + "side": "home" + }, + { + "id": "462", + "name": "Chicago Bulls", + "abbreviation": "CHI", + "side": "away" + } + ], + "description": "How many points will be scored?", + "imageUrl": "", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T08:00:00.000Z", + "startTime": "2026-10-23T20:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Basketball_6c686009-b224-4e06-8686-f5ddafc52ba8.avif", + "template": "sports-total", + "gameId": "7e8fc356-9548-4496-ae63-d791e057b337", + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "sportsMarket": { + "sport": "basketball", + "type": "total", + "subject": "contest", + "scope": { + "type": "full_contest" + }, + "metric": "points" + } + } + ], + "template": "sports-game", + "gameId": "7e8fc356-9548-4496-ae63-d791e057b337", + "eventTags": [ + { + "id": 13003, + "name": "Bucks", + "slug": "bucks" + }, + { + "id": 6961, + "name": "Bulls", + "slug": "bulls" + }, + { + "id": 13876, + "name": "Eastern", + "slug": "eastern" + }, + { + "id": 2, + "name": "Games", + "slug": "games" + } + ], + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "volumeDelta24hPct": "199.57", + "sportsMarket": { + "sport": "basketball", + "type": "moneyline", + "subject": "contest", + "scope": { + "type": "full_contest" + }, + "team": { + "symbol": "CHI", + "name": "Chicago Bulls", + "externalIds": { + "sportradarId": "583ec5fd-fb46-11e1-82cb-f4ce4684ea4c" + } + } + }, + "combos": [ + { + "contract": { + "contractId": "880920", + "contractName": "UConn Yes + Milwaukee Yes + 4 more", + "contractTicker": "CMB", + "eventTicker": "CMB-261025-76215CB40501FD7B", + "eventName": "", + "category": "Sports", + "contractStatus": "Active", + "eventType": "binary", + "instrumentSymbol": "GEMI-CMB-261025-76215CB40501FD7B-CMB", + "imageUrl": "https://assets.gemini.com/predictions/images/Baseball_in_Dirt_d2ec3f42-c768-48da-aeab-2cdd4164abe7.png", + "expiryDate": "2026-10-25T14:00:00.000Z", + "description": "UConn-College Football-Yes, Milwaukee-Pro Basketball-Yes, Atlanta-Pro Basketball-Yes, Minnesota-Pro Basketball-Yes, San Antonio-Pro Basketball-Yes, Los Angeles L-Pro Basketball-Yes [terms & conditions](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_-_Combos_Class_c3a4c1f2-a3fe-4d21-976a-2643cabf017f.pdf)" + }, + "legs": [ + { + "comboId": 880920, + "legIndex": 0, + "contractId": "836849", + "requiredOutcome": "Yes", + "contract": { + "contractId": "836849", + "contractName": "UConn", + "contractTicker": "UCO", + "eventTicker": "NCAAF-2610232300-MAS-UCO-M", + "eventName": "", + "category": "College Football", + "contractStatus": "Active", + "eventType": "binary", + "instrumentSymbol": "GEMI-NCAAF-2610232300-MAS-UCO-M-UCO", + "imageUrl": "https://assets.gemini.com/predictions/images/Connecticut_Huskies_0f9b6aba-07dc-4eaa-8610-de387d7bf5fb.avif", + "expiryDate": "2026-10-24T23:00:00.000Z", + "description": "This market resolves to Yes if UConn wins the College Football game\n between UConn and Massachusetts originally scheduled for October 23, 2026 at 7:00 PM. Otherwise, it resolves to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing the event, ESPN, Fox Sports, and the broadcast partner of the event\n. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/NCAA_FBS_Win-Loss_23ef351f-7ee0-4503-a740-5da5c95d4b2c.pdf).", + "sortOrder": 2 + } + }, + { + "comboId": 880920, + "legIndex": 1, + "contractId": "858483", + "requiredOutcome": "Yes", + "contract": { + "contractId": "858483", + "contractName": "Milwaukee", + "contractTicker": "MIL", + "eventTicker": "NBA-2610232000-CHI-MIL-M", + "eventName": "", + "category": "Pro Basketball", + "contractStatus": "Active", + "eventType": "binary", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-M-MIL", + "imageUrl": "https://assets.gemini.com/predictions/images/MIL_5d06f35a-b234-4ee0-b615-ae4eb0452046.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "description": "If Milwaukee wins the Pro Basketball game between Milwaukee and Chicago originally scheduled for October 23, 2026 at 4:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "sortOrder": 2 + } + }, + { + "comboId": 880920, + "legIndex": 2, + "contractId": "858497", + "requiredOutcome": "Yes", + "contract": { + "contractId": "858497", + "contractName": "Atlanta", + "contractTicker": "ATL", + "eventTicker": "NBA-2610232300-ATL-CHA-M", + "eventName": "", + "category": "Pro Basketball", + "contractStatus": "Active", + "eventType": "binary", + "instrumentSymbol": "GEMI-NBA-2610232300-ATL-CHA-M-ATL", + "imageUrl": "https://assets.gemini.com/predictions/images/ATL_9a65de7f-ea47-4d1e-910c-889ef04bcc9d.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "description": "If Atlanta wins the Pro Basketball game between Atlanta and Charlotte originally scheduled for October 23, 2026 at 7:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "sortOrder": 1 + } + }, + { + "comboId": 880920, + "legIndex": 3, + "contractId": "858591", + "requiredOutcome": "Yes", + "contract": { + "contractId": "858591", + "contractName": "Minnesota", + "contractTicker": "MIN", + "eventTicker": "NBA-2610232300-MIN-ORL-M", + "eventName": "", + "category": "Pro Basketball", + "contractStatus": "Active", + "eventType": "binary", + "instrumentSymbol": "GEMI-NBA-2610232300-MIN-ORL-M-MIN", + "imageUrl": "https://assets.gemini.com/predictions/images/MIN_785101e4-755a-4850-a4ff-4bed91f39e81.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "description": "If Minnesota wins the Pro Basketball game between Minnesota and Orlando originally scheduled for October 23, 2026 at 7:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "sortOrder": 1 + } + }, + { + "comboId": 880920, + "legIndex": 4, + "contractId": "861954", + "requiredOutcome": "Yes", + "contract": { + "contractId": "861954", + "contractName": "San Antonio", + "contractTicker": "SAS", + "eventTicker": "NBA-2610240130-HOU-SAS-M", + "eventName": "", + "category": "Pro Basketball", + "contractStatus": "Active", + "eventType": "binary", + "instrumentSymbol": "GEMI-NBA-2610240130-HOU-SAS-M-SAS", + "imageUrl": "https://assets.gemini.com/predictions/images/SAS_2eb39eb7-ca58-4ad8-b58d-36add7053fe6.png", + "expiryDate": "2026-10-25T13:30:00.000Z", + "description": "If San Antonio wins the Pro Basketball game between San Antonio and Houston originally scheduled for October 23, 2026 at 9:30 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "sortOrder": 2 + } + }, + { + "comboId": 880920, + "legIndex": 5, + "contractId": "861958", + "requiredOutcome": "Yes", + "contract": { + "contractId": "861958", + "contractName": "Los Angeles L", + "contractTicker": "LAL", + "eventTicker": "NBA-2610240200-LAC-LAL-M", + "eventName": "", + "category": "Pro Basketball", + "contractStatus": "Active", + "eventType": "binary", + "instrumentSymbol": "GEMI-NBA-2610240200-LAC-LAL-M-LAL", + "imageUrl": "https://assets.gemini.com/predictions/images/LAL_b9eaf22e-3646-42fa-aec4-8f64cfda7f76.png", + "expiryDate": "2026-10-25T14:00:00.000Z", + "description": "If Los Angeles L wins the Pro Basketball game between Los Angeles L and Los Angeles C originally scheduled for October 23, 2026 at 10:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "sortOrder": 2 + } + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/samples/gemini_events.json b/tests/samples/gemini_events.json new file mode 100644 index 0000000..f8a01b7 --- /dev/null +++ b/tests/samples/gemini_events.json @@ -0,0 +1,1014 @@ +{ + "data": [ + { + "id": "366148", + "title": "Chicago vs Milwaukee", + "slug": "chicago-vs-milwaukee", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232000-CHI-MIL-M", + "status": "active", + "createdAt": "2026-10-20T00:00:47.320Z", + "contracts": [ + { + "id": "366148-858483", + "label": "Milwaukee", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "If Milwaukee wins the Pro Basketball game between Milwaukee and Chicago originally scheduled for October 23, 2026 at 4:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.53", + "no": "0.48" + }, + "sell": { + "yes": "0.52", + "no": "0.47" + }, + "bestBid": "0.52", + "bestAsk": "0.53", + "lastTradePrice": "0.52" + }, + "status": "active", + "createdAt": "2026-10-20T00:00:47.320Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "ticker": "MIL", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-M-MIL", + "marketState": "open", + "abbreviatedName": "MIL", + "color": "#00471B", + "imageUrl": "https://assets.gemini.com/predictions/images/MIL_5d06f35a-b234-4ee0-b615-ae4eb0452046.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 2, + "sportsSide": "home", + "teamId": "583ecefd-fb46-11e1-82cb-f4ce4684ea4c", + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "366148-858468", + "label": "Chicago", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "If Chicago wins the Pro Basketball game between Chicago and Milwaukee originally scheduled for October 23, 2026 at 4:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.48", + "no": "0.53" + }, + "sell": { + "yes": "0.47", + "no": "0.52" + }, + "bestBid": "0.47", + "bestAsk": "0.48", + "lastTradePrice": "0.49" + }, + "status": "active", + "createdAt": "2026-10-20T00:00:47.320Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "ticker": "CHI", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-M-CHI", + "marketState": "open", + "abbreviatedName": "CHI", + "color": "#CE1141", + "imageUrl": "https://assets.gemini.com/predictions/images/CHI_40120e92-39c2-4920-9ba3-5723e95ce866.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 1, + "sportsSide": "away", + "teamId": "583ec5fd-fb46-11e1-82cb-f4ce4684ea4c", + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-20T00:00:47.320Z", + "isLive": true, + "participants": [ + { + "id": "467", + "name": "Milwaukee Bucks", + "abbreviation": "MIL", + "side": "home" + }, + { + "id": "462", + "name": "Chicago Bulls", + "abbreviation": "CHI", + "side": "away" + } + ], + "description": "Chicago vs Milwaukee", + "imageUrl": "https://assets.gemini.com/predictions/images/bb_f53a7e9b-c0fc-4a5c-a011-d0bf655f8263.avif", + "volume": "1450.6", + "volume24h": "1087.56", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T08:00:00.000Z", + "startTime": "2026-10-23T20:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "https://assets.gemini.com/predictions/images/Card_Carousel__40__b3388226-c9ca-43be-9611-88185341baf0.png", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Card_Carousel__40__b3388226-c9ca-43be-9611-88185341baf0.avif", + "events": [ + { + "id": "372825", + "title": "Chicago vs Milwaukee: Point Spread", + "slug": "chicago-vs-milwaukee-point-spread", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232000-CHI-MIL-S", + "status": "active", + "createdAt": "2026-10-22T05:26:00.779Z", + "contracts": [ + { + "id": "372825-873041", + "label": "Milwaukee wins by more than 1.5 points", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if the score differential recorded in favor of Milwaukee in the entire NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT is above 1.5. Otherwise, it resolves to No. The Underlying is the score differential (Milwaukee's score minus Chicago's score) for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.5", + "no": "0.51" + }, + "sell": { + "yes": "0.49", + "no": "0.5" + }, + "bestBid": "0.49", + "bestAsk": "0.5", + "lastTradePrice": "0.48" + }, + "status": "active", + "createdAt": "2026-10-22T05:26:00.779Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "ticker": "MIL1", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-S-MIL1", + "marketState": "open", + "abbreviatedName": "Milwaukee", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/MIL_5d06f35a-b234-4ee0-b615-ae4eb0452046.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 29, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "372825-873040", + "label": "Milwaukee wins by more than 2.5 points", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if the score differential recorded in favor of Milwaukee in the entire NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT is above 2.5. Otherwise, it resolves to No. The Underlying is the score differential (Milwaukee's score minus Chicago's score) for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.47", + "no": "0.55" + }, + "sell": { + "yes": "0.45", + "no": "0.53" + }, + "bestBid": "0.45", + "bestAsk": "0.47" + }, + "status": "active", + "createdAt": "2026-10-22T05:26:00.779Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "ticker": "MIL2", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-S-MIL2", + "marketState": "open", + "abbreviatedName": "Milwaukee", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/MIL_5d06f35a-b234-4ee0-b615-ae4eb0452046.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 28, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-22T05:26:00.779Z", + "isLive": true, + "participants": [ + { + "id": "467", + "name": "Milwaukee Bucks", + "abbreviation": "MIL", + "side": "home" + }, + { + "id": "462", + "name": "Chicago Bulls", + "abbreviation": "CHI", + "side": "away" + } + ], + "description": "What will the point difference be?", + "imageUrl": "", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T08:00:00.000Z", + "startTime": "2026-10-23T20:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Basketball_6c686009-b224-4e06-8686-f5ddafc52ba8.avif", + "template": "sports-spread", + "gameId": "7e8fc356-9548-4496-ae63-d791e057b337", + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "sportsMarket": { + "sport": "basketball", + "type": "spread", + "subject": "team", + "scope": { + "type": "full_contest" + }, + "metric": "points" + } + }, + { + "id": "372882", + "title": "Chicago vs Milwaukee: Total Points", + "slug": "chicago-vs-milwaukee-total-points", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232000-CHI-MIL-T", + "status": "active", + "createdAt": "2026-10-22T05:41:32.999Z", + "contracts": [ + { + "id": "372882-873353", + "label": "More than 210.5 points scored", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if Chicago and Milwaukee collectively score over 210.5 total points in the NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT. Otherwise, it resolves to No. The Underlying is the total points scored by both teams for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.99", + "no": "0.06" + }, + "sell": { + "yes": "0.94", + "no": "0.01" + }, + "bestBid": "0.94", + "bestAsk": "0.99" + }, + "status": "active", + "createdAt": "2026-10-22T05:41:32.999Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "ticker": "O210", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-T-O210", + "marketState": "open", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/Basketball_48baed80-84ab-4004-aba2-ed1eb5c4e2c9.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 12, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "372882-873354", + "label": "More than 212.5 points scored", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if Chicago and Milwaukee collectively score over 212.5 total points in the NBA game between Chicago and Milwaukee scheduled for 2026-10-23 at 3:00 p.m. CT. Otherwise, it resolves to No. The Underlying is the total points scored by both teams for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.98", + "no": "0.08" + }, + "sell": { + "yes": "0.92", + "no": "0.02" + }, + "bestBid": "0.92", + "bestAsk": "0.98" + }, + "status": "active", + "createdAt": "2026-10-22T05:41:32.999Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "ticker": "O212", + "instrumentSymbol": "GEMI-NBA-2610232000-CHI-MIL-T-O212", + "marketState": "open", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/Basketball_48baed80-84ab-4004-aba2-ed1eb5c4e2c9.png", + "expiryDate": "2026-10-25T08:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 13, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-22T05:41:32.999Z", + "isLive": true, + "participants": [ + { + "id": "467", + "name": "Milwaukee Bucks", + "abbreviation": "MIL", + "side": "home" + }, + { + "id": "462", + "name": "Chicago Bulls", + "abbreviation": "CHI", + "side": "away" + } + ], + "description": "How many points will be scored?", + "imageUrl": "", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T08:00:00.000Z", + "startTime": "2026-10-23T20:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Basketball_6c686009-b224-4e06-8686-f5ddafc52ba8.avif", + "template": "sports-total", + "gameId": "7e8fc356-9548-4496-ae63-d791e057b337", + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "sportsMarket": { + "sport": "basketball", + "type": "total", + "subject": "contest", + "scope": { + "type": "full_contest" + }, + "metric": "points" + } + } + ], + "template": "sports-game", + "gameId": "7e8fc356-9548-4496-ae63-d791e057b337", + "eventTags": [ + { + "id": 13003, + "name": "Bucks", + "slug": "bucks" + }, + { + "id": 6961, + "name": "Bulls", + "slug": "bulls" + }, + { + "id": 13876, + "name": "Eastern", + "slug": "eastern" + }, + { + "id": 2, + "name": "Games", + "slug": "games" + } + ], + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "volumeDelta24hPct": "199.57", + "sportsMarket": { + "sport": "basketball", + "type": "moneyline", + "subject": "contest", + "scope": { + "type": "full_contest" + }, + "team": { + "symbol": "CHI", + "name": "Chicago Bulls", + "externalIds": { + "sportradarId": "583ec5fd-fb46-11e1-82cb-f4ce4684ea4c" + } + } + } + }, + { + "id": "366154", + "title": "New York vs Boston", + "slug": "new-york-vs-boston", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232300-NYK-BOS-M", + "status": "active", + "createdAt": "2026-10-20T00:01:03.264Z", + "contracts": [ + { + "id": "366154-858534", + "label": "Boston", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "If Boston wins the Pro Basketball game between Boston and New York originally scheduled for October 23, 2026 at 7:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.55", + "no": "0.46" + }, + "sell": { + "yes": "0.54", + "no": "0.45" + }, + "bestBid": "0.54", + "bestAsk": "0.55", + "lastTradePrice": "0.55" + }, + "status": "active", + "createdAt": "2026-10-20T00:01:03.264Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "ticker": "BOS", + "instrumentSymbol": "GEMI-NBA-2610232300-NYK-BOS-M-BOS", + "marketState": "open", + "abbreviatedName": "BOS", + "color": "#007A33", + "imageUrl": "https://assets.gemini.com/predictions/images/BOS_0e8fb0cd-025c-406b-99dc-fb96158352bf.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 2, + "sportsSide": "home", + "teamId": "583eccfa-fb46-11e1-82cb-f4ce4684ea4c", + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "366154-858515", + "label": "New York", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "If New York wins the Pro Basketball game between New York and Boston originally scheduled for October 23, 2026 at 7:00 PM ET, this market will resolve to Yes. Otherwise, it will resolve to No. The Underlying is the official result of the specified sporting event. Source Agencies, in order, are the league governing body, ESPN, Fox Sports, and the broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules.](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf)", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.46", + "no": "0.55" + }, + "sell": { + "yes": "0.45", + "no": "0.54" + }, + "bestBid": "0.45", + "bestAsk": "0.46", + "lastTradePrice": "0.46" + }, + "status": "active", + "createdAt": "2026-10-20T00:01:03.264Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "ticker": "NYK", + "instrumentSymbol": "GEMI-NBA-2610232300-NYK-BOS-M-NYK", + "marketState": "open", + "abbreviatedName": "NYK", + "color": "#006BB6", + "imageUrl": "https://assets.gemini.com/predictions/images/NYK_69e0d4c9-46ed-463c-a955-526f08c73b7a.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 1, + "sportsSide": "away", + "teamId": "583ec70e-fb46-11e1-82cb-f4ce4684ea4c", + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-20T00:01:03.264Z", + "isLive": true, + "participants": [ + { + "id": "459", + "name": "Boston Celtics", + "abbreviation": "BOS", + "side": "home" + }, + { + "id": "468", + "name": "New York Knicks", + "abbreviation": "NYK", + "side": "away" + } + ], + "description": "New York vs Boston", + "imageUrl": "https://assets.gemini.com/predictions/images/bb_f53a7e9b-c0fc-4a5c-a011-d0bf655f8263.avif", + "volume": "1484.01", + "volume24h": "1441.86", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T11:00:00.000Z", + "startTime": "2026-10-23T23:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Win_Lose_Class_925aa13e-b255-4ef8-85e0-534a6f0a9012.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "https://assets.gemini.com/predictions/images/Card_Carousel__40__b3388226-c9ca-43be-9611-88185341baf0.png", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Card_Carousel__40__b3388226-c9ca-43be-9611-88185341baf0.avif", + "events": [ + { + "id": "366217", + "title": "New York vs Boston: Point Spread", + "slug": "new-york-vs-boston-point-spread", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232300-NYK-BOS-S", + "status": "active", + "createdAt": "2026-10-20T00:09:41.063Z", + "contracts": [ + { + "id": "366217-858828", + "label": "Boston wins by more than 1.5 points", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if the score differential recorded in favor of Boston in the entire NBA game between New York and Boston scheduled for 2026-10-23 at 6:00 p.m. CT is above 1.5. Otherwise, it resolves to No. The Underlying is the score differential (Boston's score minus New York's score) for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.54", + "no": "0.47" + }, + "sell": { + "yes": "0.53", + "no": "0.46" + }, + "bestBid": "0.53", + "bestAsk": "0.54" + }, + "status": "active", + "createdAt": "2026-10-20T00:09:41.063Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "ticker": "BOS1", + "instrumentSymbol": "GEMI-NBA-2610232300-NYK-BOS-S-BOS1", + "marketState": "open", + "abbreviatedName": "Boston", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/BOS_0e8fb0cd-025c-406b-99dc-fb96158352bf.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 26, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "366217-858821", + "label": "Boston wins by more than 2.5 points", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if the score differential recorded in favor of Boston in the entire NBA game between New York and Boston scheduled for 2026-10-23 at 6:00 p.m. CT is above 2.5. Otherwise, it resolves to No. The Underlying is the score differential (Boston's score minus New York's score) for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.5", + "no": "0.51" + }, + "sell": { + "yes": "0.49", + "no": "0.5" + }, + "bestBid": "0.49", + "bestAsk": "0.5" + }, + "status": "active", + "createdAt": "2026-10-20T00:09:41.063Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "ticker": "BOS2", + "instrumentSymbol": "GEMI-NBA-2610232300-NYK-BOS-S-BOS2", + "marketState": "open", + "abbreviatedName": "Boston", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/BOS_0e8fb0cd-025c-406b-99dc-fb96158352bf.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 22, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-20T00:09:41.063Z", + "isLive": true, + "participants": [ + { + "id": "459", + "name": "Boston Celtics", + "abbreviation": "BOS", + "side": "home" + }, + { + "id": "468", + "name": "New York Knicks", + "abbreviation": "NYK", + "side": "away" + } + ], + "description": "What will the point difference be?", + "imageUrl": "", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T11:00:00.000Z", + "startTime": "2026-10-23T23:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Spread_Class_98459f6c-7941-4c33-9bc5-7202c53df230.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Basketball_6c686009-b224-4e06-8686-f5ddafc52ba8.avif", + "template": "sports-spread", + "gameId": "b9a9c3c9-2be3-4536-8459-309adf65ad0a", + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "sportsMarket": { + "sport": "basketball", + "type": "spread", + "subject": "team", + "scope": { + "type": "full_contest" + }, + "metric": "points" + } + }, + { + "id": "366229", + "title": "New York vs Boston: Total Points", + "slug": "new-york-vs-boston-total-points", + "type": "categorical", + "category": "Sports", + "ticker": "NBA-2610232300-NYK-BOS-T", + "status": "active", + "createdAt": "2026-10-20T00:11:34.905Z", + "contracts": [ + { + "id": "366229-858852", + "label": "More than 195.5 points scored", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if New York and Boston collectively score over 195.5 total points in the NBA game between New York and Boston scheduled for 2026-10-23 at 6:00 p.m. CT. Otherwise, it resolves to No. The Underlying is the total points scored by both teams for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.95", + "no": "0.08" + }, + "sell": { + "yes": "0.92", + "no": "0.05" + }, + "bestBid": "0.92", + "bestAsk": "0.95" + }, + "status": "active", + "createdAt": "2026-10-20T00:11:34.905Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "ticker": "O195", + "instrumentSymbol": "GEMI-NBA-2610232300-NYK-BOS-T-O195", + "marketState": "open", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/Basketball_48baed80-84ab-4004-aba2-ed1eb5c4e2c9.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 14, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + }, + { + "id": "366229-858860", + "label": "More than 197.5 points scored", + "description": { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This market resolves to Yes if New York and Boston collectively score over 197.5 total points in the NBA game between New York and Boston scheduled for 2026-10-23 at 6:00 p.m. CT. Otherwise, it resolves to No. The Underlying is the total points scored by both teams for the specified game. Source Agencies, in order, are the league governing the event (NBA), ESPN, Fox Sports, and the official broadcast partner of the event. If the Expiration Value cannot be determined on the Expiration Date (including because the event is cancelled or a team forfeits, withdraws, or is disqualified), the Exchange may determine payouts pursuant to the Market Outcome Review Process. View the [full rules](https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf).", + "nodeType": "text" + } + ], + "nodeType": "document" + }, + "prices": { + "buy": { + "yes": "0.94", + "no": "0.1" + }, + "sell": { + "yes": "0.9", + "no": "0.06" + }, + "bestBid": "0.9", + "bestAsk": "0.94" + }, + "status": "active", + "createdAt": "2026-10-20T00:11:34.905Z", + "termsAndConditionsUrl": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "ticker": "O197", + "instrumentSymbol": "GEMI-NBA-2610232300-NYK-BOS-T-O197", + "marketState": "open", + "color": "#0f9ee7", + "imageUrl": "https://assets.gemini.com/predictions/images/Basketball_48baed80-84ab-4004-aba2-ed1eb5c4e2c9.png", + "expiryDate": "2026-10-25T11:00:00.000Z", + "effectiveDate": "2026-10-20T00:00:06.000Z", + "sortOrder": 21, + "isCombo": false, + "quantityIncrement": "0.01", + "quantityMinimum": "0.01", + "priceIncrement": "0.01", + "quoteAssetPrecision": 2, + "priceMinimum": "0.01" + } + ], + "effectiveDate": "2026-10-20T00:11:34.905Z", + "isLive": true, + "participants": [ + { + "id": "459", + "name": "Boston Celtics", + "abbreviation": "BOS", + "side": "home" + }, + { + "id": "468", + "name": "New York Knicks", + "abbreviation": "NYK", + "side": "away" + } + ], + "description": "How many points will be scored?", + "imageUrl": "", + "tags": [ + "Pro Basketball" + ], + "expiryDate": "2026-10-25T11:00:00.000Z", + "startTime": "2026-10-23T23:00:00.000Z", + "termsLink": "https://assets.gemini.com/predictions/terms_and_conditions/Terms_and_Conditions_NBA_Point_Total_Class_83dd60df-d1ae-434e-8083-776a0713244a.pdf", + "subcategory": { + "id": 14, + "slug": "sports_basketball_pro-basketball", + "name": "Pro Basketball", + "path": [ + "Sports", + "Basketball", + "Pro Basketball" + ] + }, + "socialImageUrl": "", + "featuredImageUrl": "https://assets.gemini.com/predictions/images/Basketball_6c686009-b224-4e06-8686-f5ddafc52ba8.avif", + "template": "sports-total", + "gameId": "b9a9c3c9-2be3-4536-8459-309adf65ad0a", + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "sportsMarket": { + "sport": "basketball", + "type": "total", + "subject": "contest", + "scope": { + "type": "full_contest" + }, + "metric": "points" + } + } + ], + "template": "sports-game", + "gameId": "b9a9c3c9-2be3-4536-8459-309adf65ad0a", + "eventTags": [ + { + "id": 39, + "name": "Celtics", + "slug": "celtics" + }, + { + "id": 13876, + "name": "Eastern", + "slug": "eastern" + }, + { + "id": 2, + "name": "Games", + "slug": "games" + }, + { + "id": 132, + "name": "Knicks", + "slug": "knicks" + } + ], + "sourceDetails": { + "agency": "statscore-rest-api", + "index": "Basketball" + }, + "volumeDelta24hPct": "6141.82", + "sportsMarket": { + "sport": "basketball", + "type": "moneyline", + "subject": "contest", + "scope": { + "type": "full_contest" + }, + "team": { + "symbol": "BOS", + "name": "Boston Celtics", + "externalIds": { + "sportradarId": "583eccfa-fb46-11e1-82cb-f4ce4684ea4c" + } + } + } + } + ], + "pagination": { + "limit": 2, + "offset": 0, + "total": 35 + } +} \ No newline at end of file diff --git a/tests/samples/gemini_not_found.json b/tests/samples/gemini_not_found.json new file mode 100644 index 0000000..eb979f7 --- /dev/null +++ b/tests/samples/gemini_not_found.json @@ -0,0 +1 @@ +{"error":"Event not found"} \ No newline at end of file diff --git a/tests/samples/gemini_trades.json b/tests/samples/gemini_trades.json new file mode 100644 index 0000000..6f5155a --- /dev/null +++ b/tests/samples/gemini_trades.json @@ -0,0 +1,56 @@ +[ + { + "timestamp": 1792763973, + "timestampms": 1792763973757, + "tid": 1893456012680104, + "price": "0.52", + "amount": "26.23", + "exchange": "gemini", + "type": "buy" + }, + { + "timestamp": 1792763973, + "timestampms": 1792763973757, + "tid": 1893456012680103, + "price": "0.52", + "amount": "50", + "exchange": "gemini", + "type": "buy" + }, + { + "timestamp": 1792755550, + "timestampms": 1792755550028, + "tid": 1893456012678822, + "price": "0.51", + "amount": "3.78", + "exchange": "gemini", + "type": "buy" + }, + { + "timestamp": 1792725449, + "timestampms": 1792725449821, + "tid": 1893456012673292, + "price": "0.5", + "amount": "20.93", + "exchange": "gemini", + "type": "sell" + }, + { + "timestamp": 1792715712, + "timestampms": 1792715712483, + "tid": 1893456012668190, + "price": "0.52", + "amount": "18.59", + "exchange": "gemini", + "type": "buy" + }, + { + "timestamp": 1792692791, + "timestampms": 1792692791806, + "tid": 1893456012656842, + "price": "0.51", + "amount": "947.86", + "exchange": "gemini", + "type": "buy" + } +] \ No newline at end of file diff --git a/tests/samples/gemini_trading.json b/tests/samples/gemini_trading.json new file mode 100644 index 0000000..aa25d5c --- /dev/null +++ b/tests/samples/gemini_trading.json @@ -0,0 +1,347 @@ +{ + "note": "documented response examples, specs/openapi/prediction-markets.yaml and specs/openapi/rest.yaml (developer.gemini.com), 2026-10-23; contract descriptions and image URLs dropped", + "order": { + "orderId": 12345678901, + "status": "open", + "symbol": "GEMI-FEDJAN26-DN25", + "side": "buy", + "outcome": "yes", + "orderType": "limit", + "quantity": "100", + "filledQuantity": "0", + "remainingQuantity": "100", + "price": "0.65", + "avgExecutionPrice": null, + "createdAt": "2025-12-15T10:30:00.000Z", + "updatedAt": "2025-12-15T10:30:00.000Z", + "cancelledAt": null, + "contractMetadata": { + "contractId": "contract_123", + "contractName": "FEDJAN26-DN25", + "contractTicker": "FEDJAN26-DN25", + "eventTicker": "FEDJAN26", + "eventName": "Will Fed Funds Rate drop at least 0.25% at January 2026 meeting?", + "category": "economics", + "contractStatus": "active", + "expiryDate": "2026-01-31T23:59:59.000Z", + "resolvedAt": null + } + }, + "active_orders": { + "orders": [ + { + "orderId": 12345678901, + "status": "open", + "symbol": "GEMI-FEDJAN26-DN25", + "side": "buy", + "outcome": "yes", + "orderType": "limit", + "quantity": "100", + "filledQuantity": "25", + "remainingQuantity": "75", + "price": "0.65", + "avgExecutionPrice": "0.64", + "createdAt": "2025-12-15T10:30:00.000Z", + "updatedAt": "2025-12-15T11:00:00.000Z", + "cancelledAt": null, + "contractMetadata": { + "contractId": "contract_123", + "contractName": "FEDJAN26-DN25", + "contractTicker": "FEDJAN26-DN25", + "eventTicker": "FEDJAN26", + "eventName": "Will Fed Funds Rate drop at least 0.25% at January 2026 meeting?", + "category": "economics", + "contractStatus": "active", + "expiryDate": "2026-01-31T23:59:59.000Z", + "resolvedAt": null + } + }, + { + "orderId": 12345678902, + "status": "open", + "symbol": "GEMI-FEDJAN26-DN25", + "side": "sell", + "outcome": "yes", + "orderType": "limit", + "quantity": "50", + "filledQuantity": "0", + "remainingQuantity": "50", + "price": "0.65", + "avgExecutionPrice": null, + "createdAt": "2025-12-15T12:00:00.000Z", + "updatedAt": "2025-12-15T12:00:00.000Z", + "cancelledAt": null, + "contractMetadata": { + "contractId": "contract_123", + "contractName": "FEDJAN26-DN25", + "contractTicker": "FEDJAN26-DN25", + "eventTicker": "FEDJAN26", + "eventName": "Will Fed Funds Rate drop at least 0.25% at January 2026 meeting?", + "category": "economics", + "contractStatus": "active", + "expiryDate": "2026-01-31T23:59:59.000Z", + "resolvedAt": null + } + } + ], + "pagination": { + "limit": 50, + "offset": 0, + "count": 2 + } + }, + "order_batch": { + "results": [ + { + "order": { + "orderId": 12345678901, + "status": "open", + "symbol": "GEMI-FEDJAN26-DN25", + "side": "buy", + "outcome": "yes", + "orderType": "limit", + "timeInForce": "good-til-cancel", + "quantity": "100", + "filledQuantity": "0", + "remainingQuantity": "100", + "price": "0.65", + "createdAt": "2025-12-15T10:30:00.000Z", + "updatedAt": "2025-12-15T10:30:00.000Z" + } + }, + { + "error": "InsufficientFunds", + "message": "Insufficient funds" + } + ] + }, + "cancel_batch": { + "results": [ + { + "orderId": 12345678, + "result": "ok" + }, + { + "orderId": 12345679, + "error": "OrderNotFound", + "message": "Order 12345679 not found" + } + ] + }, + "positions": { + "positions": [ + { + "symbol": "GEMI-FEDJAN26-DN25", + "instrumentId": 1001, + "totalQuantity": "125", + "quantityOnHold": "10", + "avgPrice": "0.63", + "outcome": "yes", + "contractMetadata": { + "contractId": "contract_123", + "contractName": "FEDJAN26-DN25", + "contractTicker": "FEDJAN26-DN25", + "eventTicker": "FEDJAN26", + "eventName": "Will Fed Funds Rate drop at least 0.25% at January 2026 meeting?", + "category": "economics", + "contractStatus": "active", + "eventType": "binary", + "expiryDate": "2026-01-31T23:59:59.000Z", + "resolvedAt": null, + "resolutionSide": null, + "sortOrder": null, + "parentEventTicker": null, + "template": "binary", + "color": null, + "startTime": null + }, + "prices": { + "buy": { + "true": "0.63", + "false": "0.37" + }, + "sell": { + "true": "0.61", + "false": "0.35" + }, + "bestBid": "0.61", + "bestAsk": "0.63", + "lastTradePrice": "0.62" + }, + "resolutionSide": null, + "isAboveAutoStartThreshold": false, + "isLive": true, + "realizedPl": "0", + "marketValue": "78.75", + "unrealizedPnl": "0", + "unrealizedPct": 0 + }, + { + "symbol": "GEMI-FEDJAN26-DN25", + "instrumentId": 1001, + "totalQuantity": "200", + "quantityOnHold": "0", + "avgPrice": "0.36", + "outcome": "no", + "contractMetadata": { + "contractId": "contract_123", + "contractName": "FEDJAN26-DN25", + "contractTicker": "FEDJAN26-DN25", + "eventTicker": "FEDJAN26", + "eventName": "Will Fed Funds Rate drop at least 0.25% at January 2026 meeting?", + "category": "economics", + "contractStatus": "active", + "eventType": "binary", + "expiryDate": "2026-01-31T23:59:59.000Z", + "resolvedAt": null, + "resolutionSide": null, + "sortOrder": null, + "parentEventTicker": null, + "template": "binary", + "color": null, + "startTime": null + }, + "prices": { + "buy": { + "true": "0.37", + "false": "0.63" + }, + "sell": { + "true": "0.35", + "false": "0.61" + }, + "bestBid": "0.35", + "bestAsk": "0.37", + "lastTradePrice": "0.36" + }, + "resolutionSide": null, + "isAboveAutoStartThreshold": false, + "isLive": true, + "realizedPl": "0", + "marketValue": "72.00", + "unrealizedPnl": "0", + "unrealizedPct": 0 + } + ], + "total": 2 + }, + "settled": { + "positions": [ + { + "accountId": 12345, + "instrumentId": 1001, + "instrumentSymbol": "GEMI-FEDJAN26-DN25", + "position": "125", + "positionQuantity": "125", + "outcome": "yes", + "payout": "125.00", + "resolutionSide": "yes", + "settledAt": "2026-01-31T23:59:59.000Z", + "contractMetadata": { + "contractId": "contract_123", + "contractName": "FEDJAN26-DN25", + "contractTicker": "FEDJAN26-DN25", + "eventTicker": "FEDJAN26", + "eventName": "Will Fed Funds Rate drop at least 0.25% at January 2026 meeting?", + "category": "economics", + "contractStatus": "resolved", + "eventType": "binary", + "expiryDate": "2026-01-31T23:59:59.000Z", + "resolvedAt": "2026-01-31T23:59:59.000Z", + "resolutionSide": "yes", + "sortOrder": null, + "parentEventTicker": null, + "template": "binary", + "color": null, + "startTime": null + }, + "costBasis": "78.75", + "realizedPnl": "0", + "netProfit": "46.25" + }, + { + "accountId": 12345, + "instrumentId": 1002, + "instrumentSymbol": "GEMI-FEDJAN26-NOCUT", + "position": "-200", + "positionQuantity": "200", + "outcome": "no", + "payout": "0", + "resolutionSide": "yes", + "settledAt": "2026-01-31T23:59:59.000Z", + "contractMetadata": { + "contractId": "contract_124", + "contractName": "FEDJAN26-NOCUT", + "contractTicker": "FEDJAN26-NOCUT", + "eventTicker": "FEDJAN26", + "eventName": "Will Fed Funds Rate drop at least 0.25% at January 2026 meeting?", + "category": "economics", + "contractStatus": "resolved", + "eventType": "binary", + "expiryDate": "2026-01-31T23:59:59.000Z", + "resolvedAt": "2026-01-31T23:59:59.000Z", + "resolutionSide": "yes", + "sortOrder": null, + "parentEventTicker": null, + "template": "binary", + "color": null, + "startTime": null + }, + "costBasis": "72.00", + "realizedPnl": "0", + "netProfit": "-72.00" + } + ], + "total": 2 + }, + "terms_status": { + "hasAcceptedLatest": false, + "acceptedVersion": 2, + "latestVersion": 3 + }, + "balances": [ + { + "type": "exchange", + "currency": "BTC", + "amount": "5.0", + "available": "4.5", + "availableForWithdrawal": "4.5", + "_timestamp": "2024-03-16T00:00:00.000000Z" + }, + { + "type": "exchange", + "currency": "USD", + "amount": "15000.00", + "available": "5000.00", + "availableForWithdrawal": "5000.00", + "_timestamp": "2024-03-16T00:00:00.000000Z" + } + ], + "cancel": { + "result": "ok", + "message": "Order 12345678 cancelled successfully" + }, + "errors": { + "terms": { + "error": "TERMS_NOT_ACCEPTED", + "message": "Prediction markets terms must be accepted before placing orders" + }, + "blocked": { + "error": "This account is not permitted to trade prediction markets", + "code": "ACCOUNT_GROUP_BLOCKED" + }, + "auth": { + "result": "error", + "reason": "MissingNonce", + "message": "Must provide unique monotonic increasing 'nonce' field in payload" + }, + "funds": { + "error": "InsufficientFunds", + "message": "Insufficient funds" + }, + "not_found": { + "error": "OrderNotFound", + "message": "Order 12345679 not found" + } + } +} \ No newline at end of file diff --git a/tests/samples/gemini_ws.json b/tests/samples/gemini_ws.json new file mode 100644 index 0000000..8cfe60e --- /dev/null +++ b/tests/samples/gemini_ws.json @@ -0,0 +1,197 @@ +{ + "recorded": { + "note": "frames from wss://ws.gemini.com?snapshot=-1, 2026-10-23", + "ack": { + "id": "1", + "status": 200 + }, + "error": { + "id": "3", + "status": 400, + "error": { + "code": -1013, + "msg": "Invalid stream name: GEMI-NOPE-XX@bookTicker" + } + }, + "book_ticker": { + "u": 1764530206935637, + "E": 1792773136357149223, + "s": "gemi-btc2610232100-hi82000", + "b": "0.1600", + "B": "100.00", + "a": "0.2100", + "A": "250.00", + "c": "0.2200", + "C": "63.12" + }, + "depth": [ + { + "e": "depthUpdate", + "E": 1792773137019336526, + "s": "gemi-btc2610232100-hi82000", + "U": 1764530206935864, + "u": 1764530206935864, + "b": [ + [ + "0.1600", + "100.00" + ], + [ + "0.1500", + "250.00" + ], + [ + "0.1400", + "3571.00" + ], + [ + "0.1000", + "40.00" + ] + ], + "a": [ + [ + "0.2100", + "250.00" + ], + [ + "0.2200", + "741.00" + ], + [ + "0.2400", + "40.00" + ] + ] + }, + { + "e": "depthUpdate", + "E": 1792773137485050065, + "s": "gemi-btc2610232100-hi82000", + "U": 1764530206935864, + "u": 1764530206936036, + "b": [ + [ + "0.1500", + "0.00" + ] + ], + "a": [ + [ + "0.2100", + "0.00" + ] + ] + }, + { + "e": "depthUpdate", + "E": 1792773137545020319, + "s": "gemi-btc2610232100-hi82000", + "U": 1764530206936036, + "u": 1764530206936075, + "b": [ + [ + "0.1600", + "350.00" + ] + ], + "a": [ + [ + "0.2200", + "991.00" + ] + ] + }, + { + "e": "depthUpdate", + "E": 1792773138285494606, + "s": "gemi-btc2610232100-hi82000", + "U": 1764530206936075, + "u": 1764530206936251, + "b": [ + [ + "0.1600", + "100.00" + ] + ], + "a": [] + } + ] + }, + "documented": { + "note": "examples from developer.gemini.com/prediction-markets/websocket/streams", + "trade": { + "E": 1759873803503023900, + "s": "GEMI-BTC05M2606011000-UP", + "t": 2840140956529623, + "p": "0.50", + "q": "10", + "m": true + }, + "contract_status": { + "e": "contractStatus", + "E": 1776871540195, + "s": "gemi-btc15m2604221545-hi78999d63", + "k": "btc15m2604221545", + "c": "HI78999D63", + "i": 134794, + "p": "78999.63", + "o": "Awaiting Approval", + "n": "Approved" + }, + "order_new": { + "e": "orderUpdate", + "E": 1759291847686856569, + "s": "GEMI-BTC05M2606011000-UP", + "i": 73797746498585286, + "c": "btc-5m-quote-001", + "S": "BUY", + "o": "LIMIT", + "X": "NEW", + "O": "YES", + "p": "0.48000", + "q": "10", + "z": "10", + "T": 1759291847686856569 + }, + "order_canceled": { + "e": "orderUpdate", + "E": 1759291847731455006, + "s": "GEMI-BTC05M2606011000-UP", + "i": 73797746498585286, + "c": "btc-5m-quote-001", + "X": "CANCELED", + "T": 1759291847731455006 + }, + "positions": { + "e": "positionReport", + "E": 1760000000000000000, + "u": 1759999999000000000, + "A": 12345, + "P": [ + { + "t": "ec", + "s": "GEMI-BTC05M2606011000-UP", + "a": [ + { + "t": "position", + "v": "2.5" + } + ] + } + ] + }, + "balance": { + "e": "balanceUpdate", + "E": 1768250434780000000, + "u": 1768250421600000000, + "B": [ + { + "a": "USD", + "f": "207.39", + "c": "207.39" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/test_gemini.py b/tests/test_gemini.py new file mode 100644 index 0000000..3a91a39 --- /dev/null +++ b/tests/test_gemini.py @@ -0,0 +1,335 @@ +"""Gemini Predictions: ids, the YES-space book, sports taxonomy, the catalog +walk, status, trades, candles, fees. + +The samples under `tests/samples/gemini_*.json` were recorded from +api.gemini.com's public endpoints, with no key, on 2026-10-23, then trimmed: +contract and child-event lists cut to two, an event's combo list to one. +Every request goes to an `httpx.MockTransport`; nothing here reaches Gemini. +""" +from __future__ import annotations + +import copy +import re + +import httpx +import pytest + +import synpath +from synpath import BadRequest, MarketNotFound, NotSupported +from synpath.gemini import ( + MAKER_PROMO, SCHEDULE_FROM, Gemini, candidate_event_tickers, fee_schedule_of, normalize_candle, + normalize_event, normalize_market, normalize_order_book, normalize_trade, parse_ts, plain_text, status_of, +) + +from conftest import load + +EVENTS = load("gemini_events.json") +EVENT = load("gemini_event.json") # Chicago vs Milwaukee, moneyline, with its spread and total +CRYPTO = load("gemini_crypto_event.json") # a BTC 5-minute Up/Down, binary +BOOK = load("gemini_book.json") +TRADES = load("gemini_trades.json") +CANDLES = load("gemini_candles.json") +CATEGORIES = load("gemini_categories.json") +NOT_FOUND = load("gemini_not_found.json") + +MIL = "gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL" +GAME = "gemini:NBA-2610232000-CHI-MIL-M" +OUTSIDE_PROMO = MAKER_PROMO[1] + 1 +INSIDE_PROMO = MAKER_PROMO[0] + 1 + + +class Api: + """api.gemini.com, answered from the samples. Records every request.""" + + def __init__(self, events: list | None = None, total: int | None = None): + self.requests: list[httpx.Request] = [] + self.events = events if events is not None else EVENTS["data"] + self.total = total if total is not None else len(self.events) + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + if path == "/v1/prediction-markets/events": + offset = int(request.url.params.get("offset", 0)) + limit = int(request.url.params.get("limit", 50)) + rows = self.events[offset:offset + limit] + return httpx.Response(200, json={"data": rows, "pagination": {"limit": limit, "offset": offset, + "total": self.total}}) + found = re.fullmatch(r"/v1/prediction-markets/events/([^/]+)", path) + if found: + ticker = found.group(1).upper() + for event in [EVENT, CRYPTO, *EVENT["events"]]: + if event["ticker"].upper() == ticker: + return httpx.Response(200, json=event) + return httpx.Response(404, json=NOT_FOUND) + if path == "/v1/prediction-markets/categories": + return httpx.Response(200, json=CATEGORIES) + if path.startswith("/v1/book/"): + return httpx.Response(200, json=BOOK) + if path.startswith("/v1/trades/"): + return httpx.Response(200, json=TRADES) + if path.startswith("/v2/candles/"): + return httpx.Response(200, json=CANDLES) + return httpx.Response(404, json={"error": "no route"}) + + +def adapter(api: Api | None = None) -> tuple[Gemini, Api]: + api = api or Api() + return Gemini(limiter=None, client=httpx.Client(transport=httpx.MockTransport(api.handler))), api + + +class TestIds: + def test_a_contract_is_a_market_named_by_its_instrument_symbol(self): + market = normalize_market(EVENT["contracts"][0], EVENT) + assert market.id == MIL and market.venue_market_id == "GEMI-NBA-2610232000-CHI-MIL-M-MIL" + assert market.event_id == GAME + + def test_the_venue_is_registered(self): + assert synpath.exchanges["gemini"] is Gemini and "gemini" in synpath.ids.VENUES + + def test_candidate_events_drop_one_segment_at_a_time(self): + assert candidate_event_tickers("GEMI-NBA-2610232000-CHI-MIL-M-MIL")[:2] == [ + "NBA-2610232000-CHI-MIL-M", "NBA-2610232000-CHI-MIL"] + assert candidate_event_tickers("gemi-btc05m2610231640-up") == ["BTC05M2610231640"] + with pytest.raises(BadRequest): + candidate_event_tickers("NBA-2610232000") + + +class TestNormalize: + def test_the_quote_is_the_yes_book_and_no_is_its_mirror(self): + market = normalize_market(EVENT["contracts"][0], EVENT) + assert (market.yes.quote.bid, market.yes.quote.ask) == (0.52, 0.53) + assert (market.no.quote.bid, market.no.quote.ask) == (0.47, 0.48) + assert market.yes.quote.mid == 0.525 and market.no.quote.mid == 0.475 + assert market.book_model == "shared_complement" and market.tick_size == 0.01 + + def test_a_last_price_without_a_time_stays_out_of_the_quote(self): + market = normalize_market(EVENT["contracts"][0], EVENT) + assert market.yes.quote.last is None and market.info["last_trade_price"] == 0.52 + + def test_labels_and_titles(self): + market = normalize_market(EVENT["contracts"][0], EVENT) + assert market.title == "Chicago vs Milwaukee: Milwaukee" + assert market.yes.label == "Milwaukee" and market.no.label == "Not Milwaukee" + up = normalize_market(CRYPTO["contracts"][0], CRYPTO) + assert up.title == CRYPTO["title"] and up.yes.label == "Up" + + def test_the_sports_taxonomy_rides_on_the_market(self): + market = normalize_market(EVENT["contracts"][0], EVENT) + sports = market.info["sports_market"] + assert sports["sport"] == "basketball" and sports["type"] == "moneyline" and sports["scope"]["type"] == "full_contest" + assert "basketball" in market.tags and "moneyline" in market.tags + + def test_status_and_times(self): + market = normalize_market(EVENT["contracts"][0], EVENT) + assert market.status == "open" and market.active + assert market.close_timestamp == parse_ts(EVENT["contracts"][0]["expiryDate"]) + assert market.close_datetime.endswith("Z") + + def test_the_contract_grid_is_kept(self): + info = normalize_market(EVENT["contracts"][0], EVENT).info + assert info["quantity_increment"] == "0.01" and info["quantity_minimum"] == "0.01" + assert info["price_increment"] == "0.01" + + def test_an_up_down_contract_keeps_its_strike_and_source(self): + up = normalize_market(CRYPTO["contracts"][0], CRYPTO) + assert up.info["strike"]["type"] == "reference" + assert up.settlement_sources == [{"name": "lukka", "index": "LS-LRRR-XBTUSD", "url": None}] + assert up.description and "Bitcoin" in up.description + + def test_an_event_holds_its_own_contracts_and_names_its_children(self): + event = normalize_event(EVENT) + assert event.id == GAME and [m.id for m in event.markets][0] == MIL and len(event.markets) == 2 + assert event.info["child_events"] == ["NBA-2610232000-CHI-MIL-S", "NBA-2610232000-CHI-MIL-T"] + assert event.mutually_exclusive is True + assert event.info["volume_usd"] == float(EVENT["volume"]) + + def test_a_spread_event_is_not_called_exclusive(self): + spread = normalize_event(EVENT["events"][0]) + assert spread.info["sports_market"]["type"] == "spread" and spread.mutually_exclusive is None + + @pytest.mark.parametrize("event, contract, state, expiry, status, active", [ + ("active", "active", "open", None, "open", True), + ("active", "active", "closed", 10**15, "open", False), # a pause before expiry + ("active", "active", "closed", 1, "closed", False), # past expiry + ("approved", None, None, None, "unopened", False), + ("closed", "closed", "closed", None, "closed", False), + ("under_review", None, None, None, "closed", False), + ("settled", "settled", "closed", None, "settled", False), + ("invalid", None, None, None, "settled", False), + ]) + def test_status(self, event, contract, state, expiry, status, active): + assert status_of(event, contract, state, expiry, now_ms=10**13) == (status, active) + + def test_rich_text_becomes_text(self): + doc = {"nodeType": "document", "content": [{"nodeType": "paragraph", "content": [ + {"nodeType": "text", "value": "Resolves "}, {"nodeType": "text", "value": "Yes."}]}]} + assert plain_text(doc) == "Resolves Yes." and plain_text(None) is None and plain_text("x") == "x" + + +class TestBookTradesCandles: + def test_the_book_is_yes_space(self): + book = normalize_order_book(BOOK, symbol="GEMI-NBA-2610232000-CHI-MIL-M-MIL") + assert book.market_id == MIL and book.side == "yes" and not book.derived + assert [lvl.price for lvl in book.bids] == [0.52, 0.51, 0.48] and book.best_ask.price == 0.53 + assert book.best_bid.size == 5000.0 and book.timestamp == 1792773399000 + + def test_the_no_book_mirrors_it(self): + book = normalize_order_book(BOOK, symbol="GEMI-NBA-2610232000-CHI-MIL-M-MIL", side="no", depth=1) + assert book.derived and book.depth_scope == "top_n" + assert (book.best_bid.price, book.best_bid.size) == (0.47, 8050.0) + assert (book.best_ask.price, book.best_ask.size) == (0.48, 5000.0) + + def test_a_trade_is_the_yes_price_with_the_takers_side(self): + trade = normalize_trade(TRADES[3], symbol="gemi-nba-2610232000-chi-mil-m-mil") + assert trade.market_id == MIL and trade.price == 0.5 and trade.quantity == 20.93 + assert trade.side == "sell" and trade.timestamp == 1792725449821 and trade.id == "1893456012673292" + + def test_a_bar_without_trades_is_the_last_close_repeated(self): + empty, traded = normalize_candle(CANDLES[0], interval_seconds=3600), normalize_candle(CANDLES[2], interval_seconds=3600) + assert empty.price_source == "sampled_last" and empty.volume is None + assert traded.price_source == "trade" and traded.volume == 76.23 and traded.close == 0.52 + + +class TestFees: + CURRENT = {"instrumentSymbol": "GEMI-X-Y", "createdAt": "2026-10-20T00:00:47.320Z"} + LEGACY = {"instrumentSymbol": "GEMI-X-Y", "createdAt": "2026-02-20T00:00:00.000Z"} + + @pytest.mark.parametrize("price, contracts, taker, fee", [ + # Gemini's own fee examples (gemini.com/fees/predictions). + (0.40, 100, True, 1.68), (0.90, 100, True, 0.63), (0.10, 100, False, 0.16), (0.50, 100, False, 0.44), + ]) + def test_the_published_examples(self, price, contracts, taker, fee): + schedule = fee_schedule_of(self.CURRENT, now_ms=OUTSIDE_PROMO) + assert schedule.fee_type == "quadratic_theta" and schedule.rounding == "up_to_cent" + assert schedule.estimate(price, contracts, taker=taker) == fee + + def test_the_fee_rounds_up_to_the_cent(self): + assert fee_schedule_of(self.CURRENT, now_ms=OUTSIDE_PROMO).estimate(0.5, 1) == 0.02 # 0.0175 + + def test_makers_pay_nothing_during_the_promotion(self): + schedule = fee_schedule_of(self.CURRENT, now_ms=INSIDE_PROMO) + assert schedule.maker_rate == 0.0 and schedule.estimate(0.5, 100, taker=False) == 0.0 + assert schedule.info["maker_promotion"] is True + + def test_contracts_listed_before_the_schedule_pay_the_legacy_one(self): + schedule = fee_schedule_of(self.LEGACY, now_ms=OUTSIDE_PROMO) + assert schedule.fee_type == "min_price" and (schedule.taker_rate, schedule.maker_rate) == (0.05, 0.01) + assert schedule.estimate(0.9, 100) == 0.5 # 0.05 * 0.10 * 100 + assert parse_ts(self.LEGACY["createdAt"]) < SCHEDULE_FROM + + +class TestAdapter: + def test_markets_page_through_events_with_a_cursor(self): + gemini, api = adapter() + first = gemini.fetch_markets(limit=3) + assert [m.id for m in first][:2] == [MIL, "gemini:GEMI-NBA-2610232000-CHI-MIL-M-CHI"] and len(first) == 3 + rest = gemini.fetch_markets(limit=3, cursor=first.next_cursor) + assert [m.id for m in rest] == ["gemini:GEMI-NBA-2610232300-NYK-BOS-M-NYK"] and rest.next_cursor is None + assert api.requests[0].url.params.get_list("status") == ["active"] + + def test_sports_filters_go_to_the_venue(self): + gemini, api = adapter() + gemini.fetch_markets(sport=["basketball", "baseball"], sports_market_type="moneyline", category="Sports", + query="Chicago") + params = api.requests[0].url.params + assert params.get_list("sport") == ["basketball", "baseball"] + assert params["sports_market_type"] == "moneyline" and params["category"] == "Sports" + assert params["search"] == "Chicago" + + def test_a_cursor_from_another_query_is_refused(self): + gemini, _ = adapter() + cursor = gemini.fetch_markets(limit=1).next_cursor + with pytest.raises(BadRequest): + gemini.fetch_markets(limit=1, cursor=cursor, sport="soccer") + with pytest.raises(BadRequest): + gemini.fetch_markets(cursor="not-a-cursor") + + def test_a_settled_listing_asks_for_settled_and_invalid(self): + gemini, api = adapter(Api(events=[])) + assert gemini.fetch_markets(status="settled") == [] + assert api.requests[0].url.params.get_list("status") == ["settled", "invalid"] + + def test_sort_is_refused(self): + gemini, _ = adapter() + with pytest.raises(NotSupported): + gemini.fetch_markets(sort="volume") + + def test_events_page_by_offset(self): + gemini, _ = adapter(Api(total=35)) + page = gemini.fetch_events(limit=2) + assert [e.id for e in page] == [GAME, "gemini:NBA-2610232300-NYK-BOS-M"] and page.next_cursor + + def test_a_market_is_found_through_the_events_its_symbol_could_belong_to(self): + gemini, api = adapter() + market = gemini.fetch_market("gemini:GEMI-NBA-2610232000-CHI-MIL-S-MIL1") + assert market.event_id == "gemini:NBA-2610232000-CHI-MIL-S" and market.info["sports_market"]["type"] == "spread" + assert [r.url.path.rsplit("/", 1)[1] for r in api.requests] == ["NBA-2610232000-CHI-MIL-S"] + + def test_a_listing_remembers_each_symbols_event(self): + gemini, api = adapter() + gemini.fetch_markets(limit=2) + found = gemini.fetch_markets_by_ids([MIL, "gemini:GEMI-NBA-2610232000-CHI-MIL-M-CHI", "gemini:GEMI-NOPE-X"]) + assert [m.id for m in found] == [MIL, "gemini:GEMI-NBA-2610232000-CHI-MIL-M-CHI"] + event_reads = [r.url.path for r in api.requests if r.url.path.startswith("/v1/prediction-markets/events/")] + assert event_reads == ["/v1/prediction-markets/events/NBA-2610232000-CHI-MIL-M", + "/v1/prediction-markets/events/NOPE"] + + def test_an_unknown_symbol_is_market_not_found(self): + gemini, _ = adapter() + with pytest.raises(MarketNotFound): + gemini.fetch_market("gemini:GEMI-NOPE-XYZ-ABC") + + def test_fetch_event(self): + gemini, _ = adapter() + assert gemini.fetch_event("gemini:BTC05M2610231640").markets[0].id == "gemini:GEMI-BTC05M2610231640-UP" + + def test_the_book_asks_for_every_level(self): + gemini, api = adapter() + book = gemini.fetch_order_book(MIL) + assert book.depth_scope == "full" and api.requests[0].url.path == "/v1/book/GEMI-NBA-2610232000-CHI-MIL-M-MIL" + assert api.requests[0].url.params["limit_bids"] == "0" + assert list(gemini.fetch_order_books([MIL, MIL])) == [MIL] + + def test_trades_come_oldest_first_and_page_forward_by_trade_id(self): + gemini, api = adapter() + recent = gemini.fetch_trades(MIL) + assert [t.timestamp for t in recent] == sorted(t.timestamp for t in recent) and recent.next_cursor is None + forward = gemini.fetch_trades(MIL, cursor="0", limit=6) + assert api.requests[-1].url.params["since_tid"] == "0" + assert forward.next_cursor == "1893456012680104" + with pytest.raises(BadRequest): + gemini.fetch_trades(MIL, cursor="x") + + def test_candles_use_the_venues_timeframe_names(self): + gemini, api = adapter() + candles = gemini.fetch_ohlcv(MIL, timeframe="1h", limit=2) + assert api.requests[0].url.path.endswith("/1hr") + assert [c.timestamp for c in candles] == [1792764000000, 1792767600000] + with pytest.raises(BadRequest): + gemini.fetch_ohlcv(MIL, timeframe="2h") + + def test_the_fee_schedule_reads_the_contract(self): + gemini, _ = adapter() + schedule = gemini.fetch_fee_schedule(MIL) + assert schedule.scope == "market" and schedule.scope_id == "GEMI-NBA-2610232000-CHI-MIL-M-MIL" + assert schedule.fee_type == "quadratic_theta" and schedule.taker_rate == 0.07 + + def test_categories(self): + gemini, api = adapter() + assert "Sports" in gemini.fetch_categories(status="active") + assert api.requests[0].url.params["status"] == "active" + + def test_another_venues_id_is_refused(self): + gemini, _ = adapter() + with pytest.raises(BadRequest): + gemini.fetch_order_book("kalshi:KXFOO") + + def test_sandbox(self): + assert Gemini(sandbox=True, limiter=None).http.base_url == "https://api.sandbox.gemini.com" + + def test_the_listing_does_not_change_the_sample(self): + before = copy.deepcopy(EVENTS) + gemini, _ = adapter() + gemini.fetch_markets() + assert EVENTS == before diff --git a/tests/test_gemini_live.py b/tests/test_gemini_live.py new file mode 100644 index 0000000..f3b7baf --- /dev/null +++ b/tests/test_gemini_live.py @@ -0,0 +1,60 @@ +"""Gemini Predictions against the real venue: run with `pytest -m live`. No +key needed; every call is a public read.""" +from __future__ import annotations + +import asyncio + +import pytest + +from synpath import Gemini +from synpath.ws.base import BookEvent +from synpath.ws.gemini import GeminiMarketStream + +pytestmark = pytest.mark.live + + +@pytest.fixture(scope="module") +def venue(): + with Gemini() as gemini: + yield gemini + + +@pytest.fixture(scope="module") +def markets(venue): + return venue.fetch_markets(limit=40) + + +def test_the_catalog_is_open_and_named(markets): + assert markets and all(m.status == "open" and m.yes.label and m.id.startswith("gemini:GEMI-") for m in markets) + + +def test_paging_neither_repeats_nor_drops(venue, markets): + following = venue.fetch_markets(limit=40, cursor=markets.next_cursor) + assert not {m.id for m in markets} & {m.id for m in following} + + +def test_sports_filters_return_their_family(venue): + found = venue.fetch_markets(sport="basketball", sports_market_type="moneyline", limit=10) + assert all(m.info["sports_market"]["type"] == "moneyline" for m in found) + + +def test_a_market_has_a_book_trades_candles_and_fees(venue, markets): + market = venue.fetch_market(markets[0].id) + assert market.id == markets[0].id + book = venue.fetch_order_book(market.id) + assert all(0 < level.price < 1 for level in book.bids + book.asks) + assert venue.fetch_trades(market.id, limit=5) is not None + venue.fetch_ohlcv(market.id, timeframe="1h", limit=6) + assert venue.fetch_fee_schedule(market.id).taker_rate in (0.07, 0.05) + + +def test_the_stream_sends_a_book(markets): + async def go(): + async with GeminiMarketStream() as stream: + await stream.watch_order_book([markets[0].id]) + async for event in stream: + if isinstance(event, BookEvent): + return event + + event = asyncio.run(asyncio.wait_for(go(), timeout=30)) + assert event.kind == "snapshot" and event.market_id == markets[0].id diff --git a/tests/test_gemini_trading.py b/tests/test_gemini_trading.py new file mode 100644 index 0000000..8ec129c --- /dev/null +++ b/tests/test_gemini_trading.py @@ -0,0 +1,414 @@ +"""Gemini Predictions order entry: known-answer signing, order bodies on the +YES leg, REST records, error mapping, and the adapter against a scripted +venue. Records are the venue's documented examples +(`tests/samples/gemini_trading.json`): reading them needs an account. Nothing +here reaches Gemini.""" +from __future__ import annotations + +import asyncio +import base64 +import json +from decimal import Decimal + +import httpx +import pytest + +from synpath import AuthenticationError, Client +from synpath.trading.credentials import CredentialsMissing, GeminiCredentials, load_credentials, load_gemini +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderNotFound, PermissionDenied +from synpath.trading.gemini import ( + GeminiSigner, GeminiTrading, balance_of, fill_of_order, map_error, order_of, positions_of, settlement_of, + translate_order, +) +from synpath.trading.types import ( + OrderRequest, OrderStatus, OrderType, PositionSide, Precision, Side, TimeInForce, +) +from synpath.errors import BadRequest, MarketNotFound + +from conftest import load + +DOCS = load("gemini_trading.json") +EVENT = load("gemini_event.json") +SECRET = "synpath-test-secret" +KEY = "account-test0000000000000000" +CREDS = GeminiCredentials(api_key=KEY, api_secret=SECRET) +FED = "gemini:GEMI-FEDJAN26-DN25" +MIL = "gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL" + +# Computed independently with `openssl dgst -sha384 -hmac synpath-test-secret` +# over the base64 payload (and, for the WebSocket, over base64("1776294447")). +ORDER_PAYLOAD = ( + "eyJyZXF1ZXN0IjoiL3YxL3ByZWRpY3Rpb24tbWFya2V0cy9vcmRlciIsIm5vbmNlIjoiMTc3NjI5NDQ0NyIsInN5bWJvbCI6IkdFTUktRkVESkFO" + "MjYtRE4yNSIsIm9yZGVyVHlwZSI6ImxpbWl0Iiwic2lkZSI6ImJ1eSIsIm91dGNvbWUiOiJ5ZXMiLCJxdWFudGl0eSI6IjEiLCJwcmljZSI6IjAu" + "NTAiLCJ0aW1lSW5Gb3JjZSI6Imdvb2QtdGlsLWNhbmNlbCJ9" +) +ORDER_SIGNATURE = "287a88a2772147fb4d069544d81e5ef6ee4cc496116d544f84ae88a44ffc01a9c92ef28144a462df306f77752f3d960a" +WS_SIGNATURE = "37e6fd382f7ec4cf24501d5ebf569a27e73d63fe0cc2a613c82611b9fd83b10d0ec51f4abcc686bb160c9cf9f72104fd" + + +def request(**changes) -> OrderRequest: + base = {"market_id": FED, "side": Side.BUY, "quantity": Decimal("1"), "price": Decimal("0.50")} + return OrderRequest(**{**base, **changes}) + + +def decoded(req: httpx.Request) -> dict: + return json.loads(base64.b64decode(req.headers["X-GEMINI-PAYLOAD"])) + + +class TestSigning: + def test_the_rest_payload_and_signature_are_known_answers(self): + signer = GeminiSigner(KEY, SECRET) + fields = translate_order(request()) + headers = signer.headers("/v1/prediction-markets/order", fields, nonce="1776294447") + assert headers["X-GEMINI-PAYLOAD"] == ORDER_PAYLOAD + assert headers["X-GEMINI-SIGNATURE"] == ORDER_SIGNATURE + assert headers["X-GEMINI-APIKEY"] == KEY + assert (headers["Content-Type"], headers["Content-Length"], headers["Cache-Control"]) == ("text/plain", "0", "no-cache") + + def test_the_websocket_handshake_is_a_known_answer(self): + headers = GeminiSigner(KEY, SECRET).ws_headers(nonce="1776294447") + assert headers["X-GEMINI-NONCE"] == "1776294447" and headers["X-GEMINI-PAYLOAD"] == "MTc3NjI5NDQ0Nw==" + assert headers["X-GEMINI-SIGNATURE"] == WS_SIGNATURE + + def test_the_nonce_is_unix_seconds(self): + assert GeminiSigner(KEY, SECRET, clock=lambda: 1776294447.9).nonce() == "1776294447" + + def test_a_master_key_names_its_subaccount(self): + payload = GeminiSigner("master-x", SECRET, account="primary").payload("/v1/balances", None, nonce="1") + assert json.loads(base64.b64decode(payload)) == {"request": "/v1/balances", "nonce": "1", "account": "primary"} + + def test_the_secret_is_not_in_the_repr(self): + assert SECRET not in repr(GeminiSigner(KEY, SECRET)) and SECRET not in repr(CREDS) + + +class TestOrderBody: + def test_buy_is_buying_yes_at_the_price(self): + assert translate_order(request()) == { + "symbol": "GEMI-FEDJAN26-DN25", "orderType": "limit", "side": "buy", "outcome": "yes", + "quantity": "1", "price": "0.50", "timeInForce": "good-til-cancel", + } + + def test_sell_is_buying_no_at_one_minus_the_price(self): + body = translate_order(request(side=Side.SELL, price=Decimal("0.65"))) + assert (body["side"], body["outcome"], body["price"]) == ("buy", "no", "0.35") + + def test_reduce_only_sells_what_is_held(self): + sell = translate_order(request(side=Side.SELL, price=Decimal("0.65"), reduce_only=True)) + assert (sell["side"], sell["outcome"], sell["price"]) == ("sell", "yes", "0.65") + buy = translate_order(request(side=Side.BUY, price=Decimal("0.65"), reduce_only=True)) + assert (buy["side"], buy["outcome"], buy["price"]) == ("sell", "no", "0.35") + + @pytest.mark.parametrize("changes, tif", [ + ({"type": OrderType.MARKET}, "immediate-or-cancel"), + ({"type": OrderType.MARKET, "time_in_force": TimeInForce.FOK}, "fill-or-kill"), + ({"time_in_force": TimeInForce.IOC}, "immediate-or-cancel"), + ]) + def test_time_in_force(self, changes, tif): + assert translate_order(request(**changes))["timeInForce"] == tif + + def test_post_only_is_maker_or_cancel(self): + assert translate_order(request(post_only=True))["makerOrCancel"] is True + with pytest.raises(InvalidOrder): + translate_order(request(post_only=True, time_in_force=TimeInForce.IOC)) + + @pytest.mark.parametrize("changes", [ + {"time_in_force": TimeInForce.GTD, "expires_at": 1}, + {"type": OrderType.STOP_LIMIT, "stop_price": Decimal("0.4"), "price": Decimal("0.45")}, + {"price": Decimal("0.505")}, + {"quantity": Decimal("0.005")}, + {"market_id": "kalshi:KXFOO"}, + {"market_id": "gemini:NBA-2610232000-CHI-MIL-M"}, + ]) + def test_refused_before_signing(self, changes): + with pytest.raises(InvalidOrder): + translate_order(request(**changes)) + + def test_a_finer_grid_is_accepted_with_its_precision(self): + precision = Precision(tick=Decimal("0.0001"), min_quantity=Decimal("0.01"), quantity_step=Decimal("0.01")) + assert translate_order(request(price=Decimal("0.5025")), precision=precision)["price"] == "0.5025" + + +class TestRecords: + def test_an_order_is_read_on_the_yes_leg(self): + yes_buy, yes_sell = DOCS["active_orders"]["orders"] + order = order_of(yes_buy) + assert (order.id, order.side, order.price, order.filled, order.remaining) == ( + "12345678901", Side.BUY, Decimal("0.65"), Decimal("25"), Decimal("75")) + assert order.average_price == Decimal("0.64") and order.status == OrderStatus.OPEN + sold = order_of(yes_sell) + assert sold.side == Side.SELL and sold.price == Decimal("0.65") and sold.reduce_only + + def test_a_no_buy_reads_as_a_sell_at_the_complement(self): + row = {**DOCS["order"], "side": "buy", "outcome": "no", "price": "0.35"} + order = order_of(row) + assert order.side == Side.SELL and order.price == Decimal("0.65") and not order.reduce_only + assert order.info["venue_outcome"] == "no" + + def test_status_words(self): + assert order_of({**DOCS["order"], "status": "filled"}).status == OrderStatus.CLOSED + assert order_of({**DOCS["order"], "status": "cancelled"}).status == OrderStatus.CANCELED + + def test_positions_net_on_the_yes_leg_and_keep_both_inventories(self): + [position] = positions_of(DOCS["positions"]["positions"]) + assert position.market_id == FED and position.side == PositionSide.SHORT + assert (position.inventory_yes, position.inventory_no, position.contracts) == (Decimal("125"), Decimal("200"), Decimal("75")) + assert position.entry_price == Decimal("0.64") # NO bought at 0.36 + + def test_a_settlement(self): + won, lost = (settlement_of(row) for row in DOCS["settled"]["positions"]) + assert won.market_id == FED and won.held == PositionSide.LONG and won.won and won.payout == Decimal("125.00") + assert won.pnl == Decimal("46.25") and won.cost == Decimal("78.75") + assert lost.held == PositionSide.SHORT and lost.won is False and lost.quantity == Decimal("200") + + def test_the_usd_balance(self): + from synpath.trading.types import Account + + balance = balance_of(DOCS["balances"], account=Account(venue="gemini")) + assert (balance.total, balance.available, balance.locked) == (Decimal("15000.00"), Decimal("5000.00"), Decimal("10000.00")) + + def test_an_order_that_filled_is_one_aggregate_fill(self): + fill = fill_of_order(DOCS["active_orders"]["orders"][0]) + assert (fill.quantity, fill.price, fill.side) == (Decimal("25"), Decimal("0.64"), Side.BUY) + assert fill.info["aggregate"] and fill_of_order(DOCS["active_orders"]["orders"][1]) is None + + +class TestErrors: + @pytest.mark.parametrize("body, status, kind", [ + ("terms", 403, PermissionDenied), + ("blocked", 403, PermissionDenied), + ("auth", 400, AuthenticationError), + ("funds", 422, InsufficientFunds), + ]) + def test_mapping(self, body, status, kind): + exc = BadRequest("gemini: x", body=DOCS["errors"][body], status=status) + assert isinstance(map_error(exc), kind) + + def test_a_missing_order_on_cancel(self): + exc = MarketNotFound("gemini: not found", body={"error": "NotFound"}, status=404) + assert isinstance(map_error(exc, path="/v1/prediction-markets/order/cancel"), OrderNotFound) + assert isinstance(map_error(BadRequest("x", body=DOCS["errors"]["not_found"], status=404)), OrderNotFound) + + def test_terms_say_what_to_do(self): + exc = map_error(BadRequest("x", body=DOCS["errors"]["terms"], status=403)) + assert "terms" in str(exc) and "accept" in str(exc) + + +class TestCredentials: + def test_key_and_secret(self): + creds = load_gemini({"GEMINI_API_KEY": KEY, "GEMINI_API_SECRET": SECRET}) + assert creds is not None and creds.env == "prod" and creds.account is None and creds.secrets == [SECRET] + + def test_nothing_set_is_none(self): + assert load_gemini({}) is None + + @pytest.mark.parametrize("env", [ + {"GEMINI_API_KEY": KEY}, + {"GEMINI_API_SECRET": SECRET}, + {"GEMINI_API_KEY": KEY, "GEMINI_API_SECRET": KEY}, + {"GEMINI_API_KEY": KEY, "GEMINI_API_SECRET": SECRET, "GEMINI_ENV": "staging"}, + ]) + def test_incomplete_or_wrong(self, env): + with pytest.raises(CredentialsMissing): + load_gemini(env) + + def test_sandbox_and_a_subaccount(self, tmp_path): + env = tmp_path / ".env" + env.write_text(f"GEMINI_API_KEY=master-x\nGEMINI_API_SECRET={SECRET}\nGEMINI_ENV=sandbox\nGEMINI_ACCOUNT=primary\n") + creds = load_credentials({}, dotenv=env, redact_logs=False)["gemini"] + assert creds.env == "sandbox" and creds.account == "primary" + assert GeminiTrading(creds).base_url == "https://api.sandbox.gemini.com" + + def test_the_client_builds_the_adapter(self): + client = Client({"gemini": CREDS}) + assert isinstance(client.trading("gemini"), GeminiTrading) + + +# --------------------------------------------------------------------------- +# The adapter against a scripted venue +# --------------------------------------------------------------------------- + +class Venue: + """Answers private and public routes; records every request.""" + + def __init__(self): + self.requests: list[httpx.Request] = [] + self.rate_limited = 0 + self.active = DOCS["active_orders"] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + path = req.url.path + if self.rate_limited: + self.rate_limited -= 1 + return httpx.Response(429, json={"result": "error", "reason": "RateLimited", "message": "slow down"}) + if path.startswith("/v1/prediction-markets/events/"): + ticker = path.rsplit("/", 1)[1] + return httpx.Response(200, json=EVENT) if ticker == EVENT["ticker"] else httpx.Response(404, json={"error": "Event not found"}) + if req.method == "POST": + assert req.content == b"", "a private request sends no body" + payload = decoded(req) + assert payload["request"] == path + routes = { + "/v1/prediction-markets/order": lambda: DOCS["order"], + "/v1/prediction-markets/order/batch": lambda: DOCS["order_batch"], + "/v1/prediction-markets/order/cancel": lambda: DOCS["cancel"], + "/v1/prediction-markets/order/batch/cancel": lambda: self._cancel(req), + "/v1/prediction-markets/orders/active": lambda: self.active, + "/v1/prediction-markets/orders/history": lambda: {"orders": [{**DOCS["order"], "status": "cancelled", + "orderId": 777}], "pagination": {}}, + "/v1/prediction-markets/positions": lambda: DOCS["positions"], + "/v1/prediction-markets/positions/settled": lambda: DOCS["settled"], + "/v1/prediction-markets/terms/status": lambda: DOCS["terms_status"], + "/v1/balances": lambda: DOCS["balances"], + } + if path in routes: + return httpx.Response(200, json=routes[path]()) + return httpx.Response(404, json={"error": "no route"}) + + def _cancel(self, req: httpx.Request) -> dict: + ids = decoded(req)["orderIds"] + return {"results": [{"orderId": i, "result": "ok"} for i in ids]} + + def private(self) -> list[httpx.Request]: + return [r for r in self.requests if "X-GEMINI-SIGNATURE" in r.headers] + + +def trading(venue: Venue, **kwargs) -> GeminiTrading: + from synpath.trading.limiter import BudgetLimiter + + return GeminiTrading(CREDS, client=httpx.AsyncClient(transport=httpx.MockTransport(venue)), + limiter=BudgetLimiter(read_per_second=1000, write_per_second=1000), **kwargs) + + +def run(coro): + return asyncio.run(coro) + + +class TestAdapter: + def test_create_order_sends_the_signed_payload_and_reads_back_the_order(self): + venue = Venue() + order = run(trading(venue, clock=lambda: 1776294447).create_order(request(client_order_id="mine"))) + [sent] = venue.private() + assert sent.url.path == "/v1/prediction-markets/order" and sent.headers["X-GEMINI-PAYLOAD"] == ORDER_PAYLOAD + assert sent.headers["X-GEMINI-SIGNATURE"] == ORDER_SIGNATURE + assert order.id == "12345678901" and order.client_order_id == "mine" and order.market_id == FED + assert order.status == OrderStatus.OPEN and order.side == Side.BUY + + def test_a_batch_comes_back_one_result_per_request(self): + venue = Venue() + results = run(trading(venue).create_orders([request(), request(side=Side.SELL), request(price=Decimal("0.505"))])) + assert results[0].id == "12345678901" + assert isinstance(results[1], InsufficientFunds) + assert isinstance(results[2], InvalidOrder) + [sent] = venue.private() + assert [o["outcome"] for o in decoded(sent)["orders"]] == ["yes", "no"] + + def test_cancel_reads_the_order_back(self): + venue = Venue() + order = run(trading(venue).cancel_order("12345678901")) + assert decoded(venue.private()[0])["orderId"] == 12345678901 + assert order.status == OrderStatus.CANCELED and order.filled == Decimal("25") + + def test_cancel_of_an_order_no_longer_listed(self): + venue = Venue() + order = run(trading(venue).cancel_order("42", market_id=FED)) + assert order.status == OrderStatus.CANCELED and order.id == "42" and order.market_id == FED + + def test_cancel_all_lists_then_cancels_in_batches(self): + venue = Venue() + assert run(trading(venue).cancel_all_orders(market_id=FED)) == 2 + listing, cancel = venue.private() + assert decoded(listing)["symbol"] == "GEMI-FEDJAN26-DN25" + assert decoded(cancel)["orderIds"] == [12345678901, 12345678902] + + def test_fetch_order_searches_the_active_orders_then_the_history(self): + venue = Venue() + assert run(trading(venue).fetch_order("777")).status == OrderStatus.CANCELED + assert [r.url.path for r in venue.private()] == ["/v1/prediction-markets/orders/active", + "/v1/prediction-markets/orders/history"] + with pytest.raises(OrderNotFound): + run(trading(Venue()).fetch_order("1")) + + def test_positions_take_their_filters_as_query_parameters(self): + venue = Venue() + positions = run(trading(venue).fetch_positions(event_id="gemini:FEDJAN26")) + [sent] = venue.private() + assert sent.url.params["eventTicker"] == "FEDJAN26" + assert set(decoded(sent)) == {"request", "nonce"} and decoded(sent)["request"] == "/v1/prediction-markets/positions" + assert positions[0].side == PositionSide.SHORT + + def test_balance_settlements_and_terms(self): + venue = Venue() + adapter = trading(venue) + assert run(adapter.fetch_balance()).available == Decimal("5000.00") + assert [s.won for s in run(adapter.fetch_settlements())] == [True, False] + assert run(adapter.fetch_terms_status())["hasAcceptedLatest"] is False + assert venue.private()[-1].method == "GET" + + def test_history_pages_by_offset(self): + venue = Venue() + page = run(trading(venue).fetch_orders(status="cancelled", limit=1)) + assert page.next_cursor == "1" and decoded(venue.private()[0])["status"] == "cancelled" + run(trading(venue).fetch_orders(cursor=page.next_cursor, limit=1)) + assert decoded(venue.private()[-1])["offset"] == 1 + + def test_since_reads_a_time_window(self): + venue = Venue() + page = run(trading(venue).fetch_orders(since=1775001600000)) + fields = decoded(venue.private()[0]) + assert fields["from"] == 1775001600000 and fields["to"] > fields["from"] and page.next_cursor is None + + def test_the_fee_estimate_reads_the_public_contract(self): + venue = Venue() + estimate = run(trading(venue).fetch_fee_estimate(MIL, Side.BUY, Decimal("0.40"), Decimal("100"))) + assert estimate.taker_fee == Decimal("1.68") and not venue.private() + + def test_a_rate_limited_read_is_signed_again(self): + venue = Venue() + venue.rate_limited = 1 + clock = iter([1000, 1001]) + run(trading(venue, clock=lambda: next(clock)).fetch_balance()) + first, second = venue.private() + assert decoded(first)["nonce"] == "1000" and decoded(second)["nonce"] == "1001" + + def test_a_rate_limited_write_is_not_resent(self): + from synpath.errors import RateLimitExceeded + + venue = Venue() + venue.rate_limited = 1 + with pytest.raises(RateLimitExceeded): + run(trading(venue).create_order(request())) + assert len(venue.private()) == 1 + + def test_the_capabilities_say_what_the_venue_holds(self): + assert GeminiTrading.has["create_order"] is True and GeminiTrading.has["edit_order"] is False + assert GeminiTrading.has["fetch_my_trades"] == "partial" and GeminiTrading.has["rfq"] is False + + +class TestDoctor: + def _env(self, monkeypatch, tmp_path, text: str): + from synpath.trading.credentials import ENV_NAMES + + for names in ENV_NAMES.values(): + for name in names: + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + env = tmp_path / ".env" + env.write_text(text) + return env + + def test_without_a_key_market_data_still_works(self, monkeypatch, tmp_path, capsys): + from synpath.trading.__main__ import doctor + + assert doctor(str(self._env(monkeypatch, tmp_path, ""))) == 0 + line = next(l for l in capsys.readouterr().out.splitlines() if l.startswith("gemini")) + assert "no key (optional)" in line and "GEMINI_API_KEY" in line + + def test_with_a_key_it_names_the_key_and_prints_no_secret(self, monkeypatch, tmp_path, capsys): + from synpath.trading.__main__ import doctor + + env = self._env(monkeypatch, tmp_path, f"GEMINI_API_KEY={KEY}\nGEMINI_API_SECRET={SECRET}\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + line = next(l for l in out.splitlines() if l.startswith("gemini")) + assert "configured (prod)" in line and KEY[:12] in line and SECRET not in out diff --git a/tests/test_gemini_ws.py b/tests/test_gemini_ws.py new file mode 100644 index 0000000..68eb9b9 --- /dev/null +++ b/tests/test_gemini_ws.py @@ -0,0 +1,234 @@ +"""Gemini Predictions' WebSocket, against a fake socket. Market frames were +recorded from wss://ws.gemini.com?snapshot=-1 on 2026-10-23 +(`tests/samples/gemini_ws.json`, "recorded"); account frames, and the trade +and contract-status frames no busy market printed while recording, are the +venue's documented examples ("documented").""" +from __future__ import annotations + +import asyncio +import base64 +import copy +import json +from decimal import Decimal + +import pytest + +import synpath +from synpath.trading.credentials import GeminiCredentials +from synpath.trading.types import Liquidity, OrderStatus, PositionSide, Side +from synpath.ws.base import ( + BalanceEvent, BookEvent, FillEvent, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, StreamStatusEvent, + TradeEvent, +) +from synpath.ws.gemini import GeminiMarketStream, GeminiUserStream, ns_to_ms + +from conftest import load + +SAMPLE = load("gemini_ws.json") +REC, DOC = SAMPLE["recorded"], SAMPLE["documented"] +SNAPSHOT, *DELTAS = REC["depth"] +MARKET = "gemini:GEMI-BTC2610232100-HI82000" +UP = "gemini:GEMI-BTC05M2606011000-UP" +CREDS = GeminiCredentials(api_key="account-test0000000000000000", api_secret="synpath-test-secret") + + +class FakeSocket: + def __init__(self): + self.sent: list = [] + + async def send(self, text): + self.sent.append(json.loads(text)) + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +async def watching(stream, *market_ids): + stream._ws = FakeSocket() + await stream.watch_order_book(list(market_ids)) + return stream._ws + + +class TestMarketStream: + def test_it_is_exported_and_public(self): + assert synpath.GeminiMarketStream is GeminiMarketStream + stream = GeminiMarketStream() + assert stream.url == "wss://ws.gemini.com?snapshot=-1" and stream.headers() == {} + assert GeminiMarketStream(sandbox=True).url == "wss://ws.sandbox.gemini.com?snapshot=-1" + + def test_subscribing_names_the_depth_stream(self): + async def go(): + stream = GeminiMarketStream() + socket = await watching(stream, MARKET) + await stream.watch_trades([MARKET]) + await stream.watch_ticker([MARKET]) + await stream.watch_market_status() + return socket.sent + + sent = asyncio.run(go()) + assert sent[0] == {"id": 1, "method": "SUBSCRIBE", "params": ["GEMI-BTC2610232100-HI82000@depth@100ms"]} + assert [s["params"] for s in sent[1:]] == [["GEMI-BTC2610232100-HI82000@trade"], + ["GEMI-BTC2610232100-HI82000@bookTicker"], ["contractStatus"]] + + def test_the_first_frame_is_the_book_then_deltas_apply(self): + async def go(): + stream = GeminiMarketStream() + await watching(stream, MARKET) + events = [e for frame in REC["depth"] for e in stream.handle(copy.deepcopy(frame))] + return stream, events + + stream, events = asyncio.run(go()) + snapshot, *deltas = events + assert isinstance(snapshot, BookEvent) and snapshot.kind == "snapshot" and snapshot.market_id == MARKET + assert snapshot.best_bid == Decimal("0.1600") and snapshot.best_ask == Decimal("0.2100") + assert snapshot.timestamp == ns_to_ms(SNAPSHOT["E"]) + assert [d.kind for d in deltas] == ["delta"] * 3 + book = stream.book(MARKET) + # Delta 1 removed 0.15 and 0.21; delta 2 set 0.16 to 350 and 0.22 to 991; delta 3 set 0.16 back to 100. + assert Decimal("0.1500") not in book.bids and Decimal("0.2100") not in book.asks + assert book.bids[Decimal("0.1600")] == Decimal("100.00") and book.best_ask == Decimal("0.2200") + assert stream.book(MARKET, "no").best_bid == Decimal("1") - Decimal("0.2200") + + def test_a_skipped_update_drops_the_book_and_subscribes_again(self): + async def go(): + stream = GeminiMarketStream() + socket = await watching(stream, MARKET) + stream.handle(copy.deepcopy(SNAPSHOT)) + skipped = {**DELTAS[1]} # U is past the snapshot's u + assert stream.handle(skipped) == [] + await asyncio.sleep(0) + again = stream.handle(copy.deepcopy(SNAPSHOT)) + return stream, socket.sent, again + + stream, sent, again = asyncio.run(go()) + statuses = [e for e in drain(stream) if isinstance(e, StreamStatusEvent)] + assert [s.state for s in statuses] == ["gap", "resynced"] + assert sent[-2:] == [ + {"id": 2, "method": "UNSUBSCRIBE", "params": ["GEMI-BTC2610232100-HI82000@depth@100ms"]}, + {"id": 3, "method": "SUBSCRIBE", "params": ["GEMI-BTC2610232100-HI82000@depth@100ms"]}, + ] + assert again[0].kind == "snapshot" and stream.book(MARKET).ready + + def test_a_frame_already_applied_is_ignored(self): + async def go(): + stream = GeminiMarketStream() + await watching(stream, MARKET) + stream.handle(copy.deepcopy(SNAPSHOT)) + stream.handle(copy.deepcopy(DELTAS[0])) + return stream.handle(copy.deepcopy(DELTAS[0])) + + assert asyncio.run(go()) == [] + + def test_a_reconnect_waits_for_a_new_snapshot(self): + async def go(): + stream = GeminiMarketStream() + await watching(stream, MARKET) + stream.handle(copy.deepcopy(SNAPSHOT)) + stream.on_disconnect() + await stream.on_connect() + assert not stream.book(MARKET).ready + return stream.handle(copy.deepcopy(DELTAS[0])) + + [event] = asyncio.run(go()) + assert event.kind == "snapshot" # whatever arrives first after a connect is the book + + def test_the_book_ticker_is_a_quote(self): + [quote] = GeminiMarketStream().handle(REC["book_ticker"]) + assert isinstance(quote, QuoteEvent) and quote.market_id == MARKET + assert (quote.bid, quote.ask, quote.bid_size, quote.last) == ( + Decimal("0.1600"), Decimal("0.2100"), Decimal("100.00"), Decimal("0.2200")) + + def test_a_print_names_the_taker(self): + [trade] = GeminiMarketStream().handle(DOC["trade"]) + assert isinstance(trade, TradeEvent) and trade.market_id == UP + assert trade.taker_side == Side.SELL # the buyer was the maker + assert trade.price == Decimal("0.50") and trade.timestamp == 1759873803503 + + def test_contract_status(self): + [event] = GeminiMarketStream().handle(DOC["contract_status"]) + assert isinstance(event, MarketStatusEvent) and event.state == "created" and event.native == "Approved" + assert event.market_id == "gemini:GEMI-BTC15M2604221545-HI78999D63" and event.timestamp == 1776871540195 + + def test_answers_and_refusals(self): + stream = GeminiMarketStream() + assert stream.handle(REC["ack"]) == [] and stream.handle(REC["error"]) == [] + states = [e.state for e in drain(stream)] + assert states == ["subscribed", "error"] + + +class TestUserStream: + def test_the_handshake_is_signed(self): + stream = GeminiUserStream(CREDS) + headers = stream.headers() + assert headers["X-GEMINI-APIKEY"] == CREDS.api_key + assert base64.b64decode(headers["X-GEMINI-PAYLOAD"]).decode() == headers["X-GEMINI-NONCE"] + assert stream.private and synpath.GeminiUserStream is GeminiUserStream + + def test_subscriptions(self): + async def go(): + stream = GeminiUserStream(CREDS) + stream._ws = FakeSocket() + await stream.watch_orders() + await stream.watch_my_trades() + await stream.watch_positions() + await stream.watch_balance() + return stream._ws.sent + + assert [s["params"] for s in asyncio.run(go())] == [["orders@account"], ["positions@account"], ["balances@account"]] + + def test_an_order_keeps_its_fields_across_frames(self): + stream = GeminiUserStream(CREDS) + [new] = stream.handle(DOC["order_new"]) + [cancelled] = stream.handle(DOC["order_canceled"]) + assert isinstance(new, OrderEvent) and new.order.status == OrderStatus.OPEN and new.order.side == Side.BUY + assert new.order.price == Decimal("0.48000") and new.order.quantity == Decimal("10") + assert cancelled.order.status == OrderStatus.CANCELED and cancelled.order.price == Decimal("0.48000") + assert cancelled.order.client_order_id == "btc-5m-quote-001" and cancelled.order.filled == Decimal("0") + + def test_a_fill_rides_on_the_order_frame(self): + stream = GeminiUserStream(CREDS) + stream.handle({**DOC["order_new"], "O": "NO", "p": "0.40", "q": "10", "z": "10"}) + fill_frame = {"e": "orderUpdate", "E": 1759291847800000000, "s": "GEMI-BTC05M2606011000-UP", + "i": 73797746498585286, "X": "PARTIALLY_FILLED", "q": "10", "z": "6", "Z": "4", "L": "0.40", + "t": 2840140956529700, "n": "0.07", "m": False, "T": 1759291847800000000} + order_event, fill_event = stream.handle(fill_frame) + order, fill = order_event.order, fill_event.fill + assert order.side == Side.SELL and order.price == Decimal("0.60") and order.filled == Decimal("4") + assert isinstance(fill_event, FillEvent) + assert (fill.price, fill.quantity, fill.fee, fill.liquidity) == (Decimal("0.60"), Decimal("4"), Decimal("0.07"), Liquidity.TAKER) + assert fill.id == "2840140956529700" and fill.timestamp == 1759291847800 + + def test_positions_and_a_settlement(self): + stream = GeminiUserStream(CREDS) + [event] = stream.handle(DOC["positions"]) + assert isinstance(event, PositionEvent) and event.position.side == PositionSide.LONG + assert event.position.contracts == Decimal("2.5") and event.position.market_id == UP + settled = {**DOC["positions"], "P": [{"t": "ec", "s": "GEMI-BTC05M2606011000-UP", "a": [ + {"t": "position", "v": "0"}, {"t": "settlement_payout", "v": "2.50", "c": "usd", "o": "YES"}]}]} + [close] = stream.handle(settled) + assert close.position.resolved and close.position.payout == Decimal("2.50") and close.position.won + assert close.position.info["result"] == "yes" + + def test_a_balance(self): + [event] = GeminiUserStream(CREDS).handle(DOC["balance"]) + assert isinstance(event, BalanceEvent) and event.balance.available == Decimal("207.39") + assert event.balance.currency == "USD" and event.balance.locked == Decimal("0") + + def test_narrowed_to_some_markets(self): + async def go(): + stream = GeminiUserStream(CREDS) + stream._ws = FakeSocket() + await stream.watch_orders([MARKET]) + return stream + + stream = asyncio.run(go()) + assert stream.handle(DOC["order_new"]) == [] + + +@pytest.mark.parametrize("value, expected", [(1759291847686856569, 1759291847686), (1776871540195, 1776871540195), (None, None)]) +def test_timestamps(value, expected): + assert ns_to_ms(value) == expected diff --git a/tests/test_init.py b/tests/test_init.py index 775d89a..0cce4f2 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -42,7 +42,8 @@ def test_it_writes_every_venue_asked_for_readable_by_the_owner_only(tmp_path: Pa "n", # Limitless "n", # SX Bet "n", # Betfair - "n"], # Smarkets + "n", # Smarkets + "n"], # Gemini secrets=[KEY, "us-secret"], ) assert run(env, p) == 0 @@ -64,7 +65,7 @@ def test_wrong_values_are_asked_again_with_the_reason(tmp_path: Path): p, said = scripted( ["y", "", "kid-1", str(tmp_path / "missing.pem"), str(not_pem), str(pem), "staging", "demo", "y", "0x1234", WALLET, "7", "1", - "n", "n", "n", "n", "n", "n", "n", "n"], + "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -81,7 +82,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) env = tmp_path / ".env" env.write_text("# mine\nSOMETHING_ELSE=keep\nPOLYMARKET_US_KEY_ID=old\n") os.chmod(env, 0o644) - p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) + p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) assert run(env, p) == 0 text = env.read_text() assert "# mine" in text and "SOMETHING_ELSE=keep" in text @@ -91,7 +92,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) def test_saying_no_to_everything_writes_nothing(tmp_path: Path): - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) assert run(tmp_path / ".env", p) == 0 assert not (tmp_path / ".env").exists() and any("Nothing changed." in line for line in said) @@ -104,7 +105,7 @@ def test_merge_quotes_values_with_spaces_and_updates_in_place(): @pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) + p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) assert run(tmp_path / ".env", p) == 0 assert (tmp_path / ".gitignore").read_text() == ".env\n" ignored = subprocess.run(["git", "check-ignore", "-q", ".env"], cwd=tmp_path) @@ -113,7 +114,7 @@ def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): def test_opinion_takes_a_key_it_is_given(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["OPINION_PRIVATE_KEY"] == KEY and values["OPINION_API_KEY"] == "opinion-api-key" @@ -122,7 +123,7 @@ def test_opinion_takes_a_key_it_is_given(tmp_path: Path): def test_opinion_with_an_api_key_needs_no_private_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"OPINION_API_KEY": "opinion-api-key"} assert load_credentials({}, dotenv=env, redact_logs=False)["opinion"] is None, "not enough to trade" @@ -135,7 +136,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch signed_with: list[str] = [] monkeypatch.setattr(opinion, "create_api_key", lambda key: signed_with.append(key) or "created-key") - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) assert run(tmp_path / ".env", p) == 0 assert signed_with == [KEY], "creating a key needs the private key: an empty one is asked again" assert read_dotenv(tmp_path / ".env")["OPINION_API_KEY"] == "created-key" @@ -144,7 +145,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY and values["HYPERLIQUID_ACCOUNT_ADDRESS"] == WALLET @@ -156,7 +157,7 @@ def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY @@ -165,7 +166,7 @@ def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_PRIVATE_KEY"] == KEY and values["PREDICT_FUN_ACCOUNT_ADDRESS"] == WALLET @@ -178,7 +179,7 @@ def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"PREDICT_FUN_API_KEY": "pred_sk_test"} text = " ".join(said) @@ -190,7 +191,7 @@ def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_TESTNET"] == "1" and "PREDICT_FUN_API_KEY" not in values @@ -201,7 +202,7 @@ def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): env = tmp_path / ".env" secret = "c2VjcmV0LWJ5dGVz" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n"], secrets=[KEY[2:], secret]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) assert run(env, p) == 0 values = read_dotenv(env) assert values["LIMITLESS_PRIVATE_KEY"] == KEY and values["LIMITLESS_API_TOKEN_ID"] == "token-123" @@ -212,7 +213,7 @@ def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n"], secrets=["sx-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["SX_BET_PRIVATE_KEY"] == KEY and values["SX_BET_API_KEY"] == "sx-api-key" @@ -223,7 +224,7 @@ def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n"], secrets=["sx-api-key", ""]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key", "SX_BET_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said), "a key pasted as the address is refused" @@ -234,7 +235,7 @@ def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): def test_sx_bet_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n"], secrets=["sx-api-key", ""]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key"} loaded = load_credentials({}, dotenv=env, redact_logs=False)["sx_bet"] @@ -248,3 +249,15 @@ def test_an_api_key_alone_counts_as_set_up_already(tmp_path: Path): p = Prompter(ask=lambda question: asked.append(question) or "n", ask_secret=lambda _q: "", say=lambda _: None) assert run(env, p) == 0 assert any("Set up SX Bet (already set)?" in question for question in asked) + + +def test_gemini_takes_a_key_pair_and_checks_the_secret_is_not_the_key(tmp_path: Path): + env = tmp_path / ".env" + p, said = scripted(["n"] * 10 + ["y", "account-abc", "staging", "sandbox"], secrets=["account-abc", "gem-secret"]) + assert run(env, p) == 0 + assert any("the secret is the other half" in line for line in said) + assert any("Enter one of: prod, sandbox" in line for line in said) + values = read_dotenv(env) + assert values == {"GEMINI_API_KEY": "account-abc", "GEMINI_API_SECRET": "gem-secret", "GEMINI_ENV": "sandbox"} + loaded = load_credentials({}, dotenv=env, redact_logs=False)["gemini"] + assert loaded is not None and loaded.env == "sandbox" From f2e0b4815ccf184ab83da39620f2302e29d1ef6f Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 18:00:00 +0100 Subject: [PATCH 03/19] feat: add novig Co-Authored-By: Claude Opus 5.5 --- .env.example | 15 + README.md | 9 +- docs/api.md | 27 +- docs/capabilities.md | 102 ++- docs/streaming.md | 33 + docs/trading.md | 31 +- synpath/__init__.py | 10 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 10 + synpath/ids.py | 4 +- synpath/novig.py | 797 ++++++++++++++++++ synpath/server/serve.py | 2 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 14 +- synpath/trading/credentials.py | 71 +- synpath/trading/init.py | 28 +- synpath/trading/instruments.py | 6 + synpath/trading/novig.py | 766 ++++++++++++++++++ synpath/trading/novig_signing.py | 258 ++++++ synpath/types.py | 5 +- synpath/ws/__init__.py | 5 +- synpath/ws/novig.py | 982 +++++++++++++++++++++++ tests/samples/novig_book.json | 70 ++ tests/samples/novig_event.json | 8 + tests/samples/novig_event_markets.json | 189 +++++ tests/samples/novig_events.json | 53 ++ tests/samples/novig_market.json | 39 + tests/samples/novig_markets.json | 143 ++++ tests/samples/novig_signing_vectors.json | 610 ++++++++++++++ tests/samples/novig_trades.json | 53 ++ tests/test_init.py | 35 +- tests/test_novig.py | 347 ++++++++ tests/test_novig_live.py | 50 ++ tests/test_novig_trading.py | 600 ++++++++++++++ tests/test_novig_ws.py | 477 +++++++++++ 36 files changed, 5793 insertions(+), 68 deletions(-) create mode 100644 synpath/novig.py create mode 100644 synpath/trading/novig.py create mode 100644 synpath/trading/novig_signing.py create mode 100644 synpath/ws/novig.py create mode 100644 tests/samples/novig_book.json create mode 100644 tests/samples/novig_event.json create mode 100644 tests/samples/novig_event_markets.json create mode 100644 tests/samples/novig_events.json create mode 100644 tests/samples/novig_market.json create mode 100644 tests/samples/novig_markets.json create mode 100644 tests/samples/novig_signing_vectors.json create mode 100644 tests/samples/novig_trades.json create mode 100644 tests/test_novig.py create mode 100644 tests/test_novig_live.py create mode 100644 tests/test_novig_trading.py create mode 100644 tests/test_novig_ws.py diff --git a/.env.example b/.env.example index 0934e99..b12bffc 100644 --- a/.env.example +++ b/.env.example @@ -114,3 +114,18 @@ BETFAIR_JURISDICTION= # prohibited. SMARKETS_USERNAME= SMARKETS_PASSWORD= + +# Novig: market data needs nothing. To trade, a subaccount's trading key: its +# key id and its private key file (PKCS#8 PEM, Ed25519 or P-256). Make the +# management key in the web app (Profile -> Settings -> Novig API), then open a +# subaccount with it (NovigTrading.open_subaccount), which issues the trading +# key. The management key (optional here) opens, funds and labels subaccounts +# and never places an order. NOVIG_ENV is production (real money, US identity +# and location checks; Arizona, Michigan and Nevada are restricted) or paper +# (play money at paper.novig.com). A key works only in the environment it was +# made in. +NOVIG_KEY_ID= +NOVIG_PRIVATE_KEY_PATH= +NOVIG_ENV= +NOVIG_MANAGEMENT_KEY_ID= +NOVIG_MANAGEMENT_PRIVATE_KEY_PATH= diff --git a/README.md b/README.md index 649c0d2..d730ac4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Synpath – One API for prediction markets Follow @Synpath_Dev on X **Build on prediction markets without building against each one.**
-One open-source API across 8 venues (Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet): market data, order entry, live streams, and an execution engine that remembers what it sent. In-process from Python, over REST and WebSocket from anywhere. +One open-source API across 9 venues (Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Novig): market data, order entry, live streams, and an execution engine that remembers what it sent. In-process from Python, over REST and WebSocket from anywhere. License: MIT Total Downloads @@ -82,14 +82,17 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | +| | [Novig](https://novig.com) | `novig` | ✓ sports, no key | ✓ (US, identity and location checks; Paper for play money) | WebSocket (trading key), else polled from the public book | one book per market, both sides read it | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). +Novig, a US sports exchange, lists two-outcome markets (a moneyline, a spread, a total, a Yes/No prop), each one yes/no market, `novig:01a1577c-d666-7e81-852e-68bed1446801`, under its game as the event. Its catalog, books and trades are public and need no key. Trading signs each request with a subaccount's key; Production trades real money for verified US customers outside Arizona, Michigan and Nevada, and Paper (`NOVIG_ENV=paper`) is the same API with play money. Novig's parlay RFQ, for registered liquidity providers, is not supported yet. See [capabilities](docs/capabilities.md#novig). + ## Why Synpath -- **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. +- **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Novig, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. - **Traders deserve advanced order types.** Stops, trailing stops, icebergs, OCO, brackets, TWAP and pegs, on every venue, even where the exchange has none. Orders are journaled before they're sent, so a crash never places one twice. @@ -135,7 +138,7 @@ fee.estimate(price=0.50, contracts=100) # 1.75 **Same code, every venue** ```python -for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets'] (Betfair and predict.fun need keys) +for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets', 'novig'] (Betfair and predict.fun need keys) with synpath.exchange(venue_id) as venue: page = venue.fetch_markets(limit=5) diff --git a/docs/api.md b/docs/api.md index dfbf90e..941dca0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,7 +77,7 @@ and spot exchanges do not. | Method | Returns | Notes | |---|---|---| | `fetch_markets(query, limit, cursor, status, sort)` | `Page[Market]` | `limit` up to 100 | -| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read), order preserved, closed and settled markets included | +| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read; Novig one request each), order preserved, closed and settled markets included | | `fetch_events(query, limit, cursor, status)` | `Page[Event]` | markets nested | | `fetch_market(market_id)` | `Market` | | | `fetch_order_book(market_id, side, depth)` | `OrderBook` | `side="yes"` (default) or `"no"` | @@ -122,6 +122,22 @@ book.info["bids"], book.info["offers"] # Smarkets' own [price bps, quantity] smk.fetch_trades(contracts[0].id) # the short recent tape ``` +Novig needs no key for market data. It adds `fetch_event(id)` (one game with +its markets), `outcome_ids(market_id)` (the YES and NO outcome ids an order +names), and the raw `fetch_novig_market` and `fetch_novig_event`, and takes +`league` (`"NFL"`, `"NBA"`), `market_type` (`"MONEY"`, `"SPREAD"`, `"TOTAL"`) +and `event` on `fetch_markets` (`league` on `fetch_events`). `env="paper"` +reads the Paper exchange, whose ids differ from Production's. See +[Novig](capabilities.md#novig). + +```python +nv = synpath.Novig() # no key; Novig(env="paper") for Paper +games = nv.fetch_markets(league="NFL", market_type="MONEY", limit=9) +book = nv.fetch_order_book(games[0].id) # bids on the YES outcome, bids on the NO outcome as asks +book.info["orders"] # Novig's own resting orders by outcome id +nv.fetch_fee_schedule(games[0].id).info["charged"] # "WHEN_LIVE": takers pay only in play +``` + Polymarket also has `fetch_order_books_by_token(token_ids, depth)`, by CLOB token id. @@ -204,7 +220,7 @@ result.matched # MarketLink | None Matching covers every venue this library speaks: `kalshi`, `polymarket`, `polymarket_us`, `opinion`, `hyperliquid`, `predict_fun`, `limitless` and -`sx_bet` (not Betfair or Smarkets yet). `venue=` asks one of them in particular; without it the answer is +`sx_bet` (not Betfair, Smarkets or Novig yet). `venue=` asks one of them in particular; without it the answer is the first venue listing the same question, in the order Polymarket, Kalshi, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Polymarket US. @@ -256,13 +272,16 @@ A venue that cannot filter by one of them raises `NotSupported`. Polymarket does for `settled`, since its catalog does not publish resolution status, and Betfair for everything but `open`, since a closed market leaves its catalogue (read it by id instead). Smarkets filters on its events' states: `closed` is an -ended or suspended event, `settled` a settled or cancelled one. +ended or suspended event, `settled` a settled or cancelled one. Novig filters +on its events' states too, `closed` being a final or cancelled event whose +markets await their grades; its public catalog drops a market once it +settles, so `settled` raises (read a settled market by id with a key). Anything outside the four raises `BadRequest` rather than being handed to the venue to interpret for itself. ### Identifiers -One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). +One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. On Novig a market is a two-outcome market, `novig:` (`novig:01a1577c-d666-7e81-852e-68bed1446801`), and its event the game, `novig:`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). ### Paging diff --git a/docs/capabilities.md b/docs/capabilities.md index b521b2a..ec2bcb9 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -6,21 +6,21 @@ Read `has` before you call. A capability a venue lacks raises `NotSupported` rather than returning an empty list, so "cannot" is never mistaken for "there is nothing". -| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | -|---|---|---|---|---|---|---|---|---|---|---| -| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | -| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | -| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | -| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | -| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | -| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | -| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | -| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | -| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | -| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | -| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | -| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | -| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | `novig` | +|---|---|---|---|---|---|---|---|---|---|---|---| +| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | yes, the outcome Novig lists first as YES, under the venue's names for both sides, newest first, with each outcome's best bid; filter by league, market type or event, no key | +| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | yes, one per game (or futures question), its markets read one request per event | +| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | yes, open or closed; settled from history with a key | +| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | yes, top 20 levels per outcome: bids on the YES outcome are YES bids, bids on the NO outcome YES asks; NO mirrored | +| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | yes, one request per market | +| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | yes, the venue's public tape, paged | +| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | partial: built from the public tape, with volume; at most ten pages of 500 prints back | +| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | +| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | yes, the market's own coefficient and maker credit, and when it charges (in play on game markets) | +| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | partial: no venue search; event descriptions matched here over the open catalog | +| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | yes, one request per market | +| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | no: the catalog lists newest first | +| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | no | Polymarket US is the CFTC-regulated exchange, not the on-chain CLOB the `polymarket` adapter reads; the two share a brand and nothing else. It has @@ -177,6 +177,48 @@ Requests carry a `synpath/` User-Agent: Smarkets' Cloudflare front answers the default ones of HTTP libraries (python-httpx, curl) with a 403 page. A 403 that is not Smarkets' own JSON is reported as Cloudflare's. +### Novig + +Novig is a US sports exchange. Every market is a question with two named +outcomes ("EDM -1.5" against "TOR +1.5", "Over 6.5" against "Under 6.5", +"Yes" against "No" on a prop; a soccer result is three Yes/No markets). The +outcome Novig lists first is YES and the other NO, and the names stay on +`market.yes.label` and `market.no.label`; `market.yes.venue_token_id` and +`market.no.venue_token_id` are the venue's outcome ids. Each game is an event +(`novig:`) holding its moneyline, spreads, totals and props; +`info["market_type"]` and `info["strike"]` say which. A futures question is +an event of its own. + +Every order buys one outcome, so a bid on one outcome is an offer of the +other at one minus its price: the book read on YES has the YES outcome's bids +as bids and the NO outcome's as asks. Prices sit on a grid of 0.001 near the +ends (to 0.050 and from 0.950) and 0.005 between. The venue's contract pays 1 +cent, so its quantities, volumes and open interest are divided by 100 here; +its own figures stay in `info`. + +The public catalog holds a market from its listing until it settles. `open` +lists the markets of events before or in play; `closed` those of finished or +cancelled events awaiting their grades; `settled` raises. With a key, +`fetch_market` reads a settled market, and its grade, from market history: a +winner (`info["result"]` `yes` or `no`), a push, or a fair-market-value +settlement (`void`, with the YES price in `info["fmv_yes"]`). The edge in +front caches public answers for a few seconds and throttles per IP. + +Fees are per market: a taker pays `coefficient * P * (1 - P)` per contract +(0.06 today) and the maker earns `makerCredit` of that back. Game markets +charge only while the event is in play (`info["charged"]` `WHEN_LIVE`), so a +fill matched pregame pays nothing and the estimate is the in-play charge; +futures markets on some leagues charge always (`ALWAYS`). Every resting order +on an event is voided when it goes in play. + +Production (`api.novig.com`) trades real money for customers who have passed +Novig's identity check, from a US location outside Arizona, Michigan and +Nevada, with a recent device geolocation from the Novig app; a refused +location is a `PermissionDenied` naming the check. Paper (`api.paper.novig.com`, +`NOVIG_ENV=paper`) is the same API with play money and no location check. +Ids and keys differ between the two. Novig's parlay RFQ, for registered +liquidity providers, is not supported yet. + **What you may do with it.** Smarkets' API terms allow viewing prices and betting on your own account; odds scraping is prohibited. Which countries Smarkets refuses API use from is not published with its API documentation and @@ -209,20 +251,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | -|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | -| `rfq` | yes | no | no | no | no | no | no | no | no | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | +|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | +| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -258,6 +300,8 @@ Every stream answers the `watch_*` keys the same way | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | +| `NovigMarketStream` | yes, per order, with a trading key; partial without one: the public book polled every 5 s | yes; partial when polled | yes; partial when polled | yes; partial when polled | no | no | no | no | +| `NovigUserStream` | no | no | no | no | yes | yes | yes | no | The exchange API's other gRPC streams carry one thing each and say so: drop copy and trade capture carry fills, position and position-change carry diff --git a/docs/streaming.md b/docs/streaming.md index 7a577f6..d0f480e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -43,6 +43,8 @@ asyncio.run(main()) | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | +| `NovigMarketStream` | `api.novig.com/v3/ws` (or Paper's); without a key, the public REST book polled | a subaccount's trading key for the WebSocket (the handshake is signed); none to poll | books kept per order, top of book, the tape, market transitions (going in play voids resting orders) | +| `NovigUserStream` | `api.novig.com/v3/ws` | a subaccount's trading key | orders (open, fill, cancel, reject) and positions of that subaccount | Each stream's `has` answers the `watch_*` capability keys (`watch_order_book`, `watch_ticker`, `watch_trades`, `watch_market_status`, `watch_orders`, @@ -69,6 +71,7 @@ variable `synpath serve` reads it from: | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | | Betfair | the application key and a login (every Betfair call needs one) | `BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD` | | Smarkets | nothing; an optional login undelays the quotes | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | +| Novig | the WebSocket: a subaccount's trading key (key id and private key file; the handshake is signed). Without one the public book is polled | `NOVIG_KEY_ID`, `NOVIG_PRIVATE_KEY_PATH`, `NOVIG_ENV` | `synpath serve` builds a market stream for Opinion, predict.fun and SX Bet from the API key alone (`load_market_data`), so a parent on any venue can @@ -204,6 +207,36 @@ jittered exponential backoff between attempts. says so in `info["delayed"]`. A change shows at the next poll, not as it happens, so its `watch_*` keys are `partial`. No `watch_trades`: the recent tape is short and unpaged; read it with `Smarkets.fetch_trades`. +- *Novig* carries every channel on one WebSocket, `wss://api.novig.com/v3/ws` + (Paper: `wss://api.paper.novig.com/v3/ws`), opened with a subaccount's + `trading` or `trading::read` key: the upgrade is signed like any request + (NOVIG-V3 over `GET /v3/ws`). Requests are JSON verbs with nonces that + grow within a connection. `subscribe` sets one channel per market -- + `book`, `bbo`, `trades` or `lifecycle`, the first three carrying + `lifecycle` too -- and is answered with a snapshot at a `seq`; each delta + carries the next `seq` of its market and channel, and a skipped one is a + gap, recovered with a `snapshot` request that leaves the subscriptions as + they are. Books are kept per resting order (`add`, `update`, `remove`) and + read on YES like the REST book: the YES outcome's bids are bids, the NO + outcome's asks at one minus their price, sizes in dollar contracts. A + market watched for its book and its trades is subscribed on `book`, and + its trades are read off the book's fills, without the tape's ids; a market + watched for trades alone is on `trades`, for its ticker alone on `bbo`. + `GOLIVE` voids every resting order on the event, so the book empties and a + `MarketStatusEvent` says so. Subscriptions go out sixteen markets a + request, paced under the venue's `stream` throttle. Without a key, + `NovigMarketStream` polls the public book (every 5 s by default), the tape + for watched trades and the market for its status, and its `watch_*` keys + are `partial`; the edge caches those answers for up to fifteen seconds. A + poll the edge throttles keeps the last answer and is retried at the next. + `NovigUserStream` subscribes the subaccount's `orders` and `positions`: + the snapshot lists the resting orders and the nonzero positions, then + `open`, `fill`, `cancel` and `reject` events and position changes follow, + each channel with its own `seq`. A heartbeat every 15 s names the last + `seq` of each; one ahead of the stream's is a gap, reported with + `reconcile_required` and recovered with a snapshot. Fills matched on entry + arrive before the order's `open`. A `fill` carries no fee; read it with + `NovigTrading.fetch_my_trades`. **A book is right or marked not ready.** After a gap or a disconnect the book's `ready` is false until a snapshot arrives; deltas are not applied to diff --git a/docs/trading.md b/docs/trading.md index 27a5edb..05b3a75 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -25,7 +25,7 @@ pip install synpath Order entry ships in the base package. The install carries the venues' signing stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, -Ed25519 and private-key JWT for Polymarket US. Nothing further is needed to place an order. +Ed25519 and private-key JWT for Polymarket US, Ed25519 or P-256 request signing (NOVIG-V3) for Novig. Nothing further is needed to place an order. Polymarket and Opinion orders are hashed and signed by the Rust core (`synpath._core`), in about 20 microseconds rather than the 3 milliseconds @@ -71,6 +71,7 @@ private key. `synpath init` tells them apart. | predict.fun | mainnet: the API key (`pred_sk_...`). Test network: nothing | the API key and a private key: a plain wallet's, or a Predict account's owner key with its deposit address | the key at developers.predict.fun; a web-app account's owner key under Account -> Settings -> Privy wallet | | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | +| Novig | REST: nothing. Books over the WebSocket: a subaccount's trading key (without one, the public book is polled) | a subaccount's trading key: a key id and its private key file (PEM, Ed25519 or P-256). The management key, optional, opens, funds and labels subaccounts and never trades | the management key on novig.com (paper.novig.com for Paper) -> Profile -> Settings -> Novig API; then `NovigTrading.open_subaccount` with a keypair from `novig_signing.generate_key()` issues the trading key. The private key is never sent | For Opinion, predict.fun and SX Bet the API key alone is a complete market-data setup: `synpath init` asks for it first and takes an empty @@ -95,6 +96,7 @@ The variables each venue reads: | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | +| Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, optional `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` and `BETFAIR_JURISDICTION`) @@ -108,6 +110,33 @@ Smarkets needs no credentials for market data. `SMARKETS_USERNAME` and through Smarkets' API needs its approved API access (applied for, with a one-off £150 fee). See [Smarkets](capabilities.md#smarkets). +Novig trades on one subaccount per trading key. An order buys an outcome: +`buy` the market's YES outcome at the price given, `sell` its NO outcome at +one minus it, snapped onto the venue's price grid on the cheaper side, in +sizes of 0.01 contract (the venue's 1-cent contract). Placement is queued: +`create_order` returns the order `pending`, and the private stream or a read +says when it rests, fills or is rejected. A batch of up to 256 is placed all +or nothing. Every resting order on an event is voided when the event goes in +play. Production needs Novig's identity check and a US location outside +Arizona, Michigan and Nevada, with a recent device geolocation from the Novig +app (a VPN is refused); Paper is the same API with play money and no location +check. `NovigTrading.echo()` proves the host, key, clock and signature in one +call. Novig's parlay RFQ, for registered liquidity providers, is not +supported yet. See [Novig](capabilities.md#novig). + +```python +from decimal import Decimal + +from synpath.trading import novig_signing +from synpath.trading.credentials import load_credentials, require +from synpath.trading.novig import NovigTrading + +async with NovigTrading(require("novig", load_credentials())) as nv: # with the management key loaded + private_pem, public_pem = novig_signing.generate_key() # keep private_pem in a secret store + desk = await nv.open_subaccount("desk-1", public_pem) # desk["keyId"] is NOVIG_KEY_ID + await nv.fund_subaccount(desk["keyId"], Decimal("250"), client_transfer_id="desk-1-topup-1") +``` + ## Through your own server The engine's order types (stops, icebergs, TWAP, orders on a bucket) live in diff --git a/synpath/__init__.py b/synpath/__init__.py index b43731a..99cfb42 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -49,6 +49,7 @@ from .sx_bet import SxBet from .betfair import Betfair from .smarkets import Smarkets +from .novig import Novig from .matching import match_event, match_market from .history import fetch_order_book_at, fetch_order_book_range, fetch_trades_range from .bucket import Bucket, BucketMember, BucketOrderReport, BucketPosition @@ -95,6 +96,7 @@ "sx_bet": SxBet, "betfair": Betfair, "smarkets": Smarkets, + "novig": Novig, } """Every venue this library speaks, by id.""" @@ -115,7 +117,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -145,7 +147,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -167,7 +169,7 @@ def __getattr__(name: str): __all__ = [ - "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Exchange", "exchange", "exchanges", + "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Novig", "Exchange", "exchange", "exchanges", "Capability", "HttpClient", "RateLimiter", "Market", "Event", "Outcome", "Quote", "MarketStats", "OrderBook", "OrderLevel", "Trade", "Candle", "FeeSchedule", "Series", "Page", @@ -184,7 +186,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index 20f063a..ad03274 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -383,7 +383,13 @@ def _sx_bet(creds: Credentials) -> TradingExchange: return SxBetTrading(creds) # type: ignore[arg-type] +def _novig(creds: Credentials) -> TradingExchange: + from .trading.novig import NovigTrading + return NovigTrading(creds) # type: ignore[arg-type] + + _TRADING = { "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, + "novig": _novig, } diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..4f3606c 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -65,6 +65,7 @@ "predict_fun": ("..trading.predict_fun", "PredictFunTrading"), "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), + "novig": ("..trading.novig", "NovigTrading"), } diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..500b2e8 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -164,6 +164,16 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] # login, when configured, only undelays the quotes. from ..ws.smarkets import SmarketsMarketStream out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) + elif venue == "novig": + # The websocket takes a subaccount's trading key; without one the + # market stream polls the public book. + from ..ws.novig import NovigMarketStream, NovigUserStream + catalog = getattr(adapter, "catalog", None) + keyed = bool(getattr(creds, "key_id", None)) + out[venue] = VenueStreams( + market=NovigMarketStream(credentials=creds, catalog=catalog), + private=NovigUserStream(credentials=creds, trading=adapter, catalog=catalog) if keyed else None, + ) else: log.info("synpath.engine.feeds: no stream for %s; its orders reach the engine by polling", venue) return out diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..fbd7107 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -4,7 +4,7 @@ the venue's own id, separated by a colon: `kalshi:KXFEDDECISION-26SEP-C25`, `polymarket:2252244`, `polymarket_us:tec-mlb-nlchamp-2026-09-27-atl`, `opinion:5342`, `hyperliquid:7173`, `betfair:1.234567890-47972`, -`smarkets:164973231-456060183`. Nothing is hashed or re-keyed, so the part +`smarkets:164973231-456060183`, `novig:01a1577c-d666-7e81-852e-68bed1446801`. Nothing is hashed or re-keyed, so the part after the colon is what the venue's own website and API call the thing. The venue in the id is what lets one field route a call: `synpath.Client` @@ -16,7 +16,7 @@ """ from __future__ import annotations -VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") +VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets", "novig") """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.""" diff --git a/synpath/novig.py b/synpath/novig.py new file mode 100644 index 0000000..2514ba9 --- /dev/null +++ b/synpath/novig.py @@ -0,0 +1,797 @@ +"""Novig: a US sports exchange, read from its public catalog. + +**No key is needed for market data.** Events, markets with their outcomes and +top of book, order books and the trade tape all answer an unsigned GET under +`/v3/public/`. The edge network in front caches each answer for a few +seconds and throttles per IP. A key (`NOVIG_KEY_ID`, see `NovigCredentials`) +adds only the history routes, which serve markets no longer in the open +catalog; `fetch_market` falls back to them when one is given. + +Two environments with the same API and separate ids: Production +(`api.novig.com`, real money) and Paper (`api.paper.novig.com`, play money). + +Four things about this venue that shape the adapter: + +**A market is a binary question with two named outcomes.** "EDM -1.5" +against "TOR +1.5", "Over 6.5" against "Under 6.5", "Yes" against "No" on a +prop. A three-way soccer result is three Yes/No markets already. The +outcome Novig lists first (the lower `outcomeId`: the Over, the Yes, the side +a spread's description names) is this library's YES and the other its NO, +as on SX Bet; the names stay on the outcomes as their labels. The order is +fixed for the market's life, since outcome ids are. An event is one game, or +one futures question, with its markets under it. + +**Every order buys an outcome, so one book serves both sides.** Each outcome +has a ladder of resting bids; a bid on one outcome at `p` is an offer of the +other at `1 - p`. Read on YES, YES bids are bids and NO bids are asks at one +minus their price. Prices sit on a grid of 0.001 near the ends and 0.005 in +the middle (see `synpath.trading.novig_signing`). + +**Quantities are in cents.** The venue's contract pays 1 cent, so its `qty` +is a hundredth of this library's dollar contract: every size, volume and open +interest here is divided by 100. Its own numbers stay in `info`. + +**The taker pays `c * P * (1 - P)` per contract**, at the market's own +coefficient (0.06 today), and the maker earns a share of it back (the maker +credit). Game markets charge only while the event is in play; futures +markets on some leagues always. See `fee_schedule_of`. + +Restricted states, KYC and geolocation apply to trading on Production only; +reading the catalog needs none of it. +""" +from __future__ import annotations + +import threading +import time +from decimal import Decimal +from typing import Any + +import httpx + +from . import ids +from .base import ( + MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort, check_status, page_limit, + pick_bars, timeframe_seconds, +) +from .errors import BadRequest, ExchangeError, MarketNotFound, NotSupported +from .types import ( + BookSide, Candle, Event, FeeSchedule, Market, MarketStats, OrderBook, OrderLevel, Outcome, Page, Quote, + Trade, iso, +) + +VENUE = "novig" +API_URL = "https://api.novig.com" +PAPER_API_URL = "https://api.paper.novig.com" +API_URLS = {"production": API_URL, "paper": PAPER_API_URL} + +QTY_SCALE = 100 +"""The venue's contract pays 1 cent: 100 of them are one dollar contract.""" + +LIMITER = RateLimiter(4.0, burst=8) +"""The public routes are throttled per IP at the edge, at a rate the venue +does not publish; kept modest, shared process-wide.""" + +BOOK_DEPTH = 20 +"""Price levels per outcome the book route serves at most (its default).""" + +PAGE = 5000 +"""Rows per listing request: the venue's maximum.""" + +EVENT_PAGE = 20 +"""Events one `fetch_events` page reads when no limit is given: one request +for the page, then one for each event's markets.""" + +MAX_PAGES = 20 +"""Most listing pages one walk reads.""" + +MAX_TRADE_PAGES = 10 +"""Most tape pages one candle read walks back through.""" + +EVENTS_TTL = 30.0 +"""Seconds the open events (for market titles and states) are reused.""" + +OPEN_EVENTS = ("OPEN_PREGAME", "OPEN_INGAME", "DELAYED", "CLOSED_PREGAME") +CLOSED_EVENTS = ("FINAL", "CANCELED") +EVENT_STATUSES: dict[str, tuple[str, ...]] = {"open": OPEN_EVENTS, "closed": CLOSED_EVENTS, "all": ()} +"""The event states a listing's `status` asks for (`all`: no filter). The +public catalog holds events until their markets settle, and nothing after.""" + +TRADABLE_EVENTS = frozenset({"OPEN_PREGAME", "OPEN_INGAME", "DELAYED"}) + + +# --------------------------------------------------------------------------- +# Pure normalizers +# --------------------------------------------------------------------------- + +def contracts(qty: Any) -> float: + """The venue's 1-cent contracts as dollar contracts.""" + return round(int(qty or 0) / QTY_SCALE, 6) + + +def price(value: Any) -> float | None: + try: + return round(float(value), 6) + except (TypeError, ValueError): + return None + + +def sides(market: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """(YES outcome, NO outcome): the one Novig lists first, by outcome id, + and the other.""" + outcomes = sorted((o for o in market.get("outcomes") or [] if isinstance(o, dict) and o.get("outcomeId")), + key=lambda o: str(o["outcomeId"])) + if len(outcomes) != 2: + raise ExchangeError(f"{VENUE}: market {market.get('marketId')} has {len(outcomes)} outcomes, not two") + return outcomes[0], outcomes[1] + + +def side_ids(market: dict[str, Any]) -> tuple[str, str]: + yes, no = sides(market) + return str(yes["outcomeId"]), str(no["outcomeId"]) + + +def quotes_of(market: dict[str, Any]) -> tuple[Quote, Quote]: + """YES and NO top of book from each outcome's best bid: YES is bid at its + own best bid and offered at one minus NO's. `last` is the latest trade.""" + yes, no = sides(market) + yes_bid, no_bid = yes.get("bid") or {}, no.get("bid") or {} + bid, bid_size = price(yes_bid.get("price")), contracts(yes_bid.get("qty")) if yes_bid else None + no_price = price(no_bid.get("price")) + ask = round(1 - no_price, 6) if no_price is not None else None + ask_size = contracts(no_bid.get("qty")) if no_bid else None + mid = round((bid + ask) / 2, 6) if bid is not None and ask is not None else None + last = price(yes.get("last")) + if last is None and price(no.get("last")) is not None: + last = round(1 - price(no.get("last")), 6) # type: ignore[operator] + yes_quote = Quote(bid=bid, bid_size=bid_size, ask=ask, ask_size=ask_size, mid=mid, last=last) + no_quote = Quote( + bid=round(1 - ask, 6) if ask is not None else None, bid_size=ask_size, + ask=round(1 - bid, 6) if bid is not None else None, ask_size=bid_size, + mid=round(1 - mid, 6) if mid is not None else None, last=round(1 - last, 6) if last is not None else None, + ) + return yes_quote, no_quote + + +def result_of(market: dict[str, Any]) -> tuple[str | None, str | None]: + """(result on YES, fair-market price of YES) from the outcomes' grades: + `yes` or `no` for a winner, `void` for a push or a fair-market-value + settlement, whose YES price rides as the second value.""" + yes, no = sides(market) + grade_yes, grade_no = str(yes.get("status") or "TBD"), str(no.get("status") or "TBD") + if grade_yes == "WIN" or grade_no == "LOSS": + return "yes", None + if grade_no == "WIN" or grade_yes == "LOSS": + return "no", None + if grade_yes == "PUSH" or grade_no == "PUSH": + return "void", None + if grade_yes not in ("TBD", ""): + return "void", grade_yes + return None, None + + +def status_of(market_status: str | None, event_status: str | None) -> tuple[str, bool]: + """(normalized status, accepting orders) from the market's and its + event's states. A market of a finished or cancelled event waits for its + grade: closed.""" + if market_status == "SETTLED" or event_status == "SETTLED": + return "settled", False + if market_status == "CLOSED" or event_status in CLOSED_EVENTS: + return "closed", False + if market_status == "OPEN": + return "open", event_status in TRADABLE_EVENTS or event_status is None + return "closed", False + + +def tags_of(event: dict[str, Any] | None) -> list[str]: + seen: dict[str, None] = {} + for word in ((event or {}).get("sport"), (event or {}).get("league")): + if word and str(word).strip(): + seen.setdefault(str(word).strip().lower(), None) + return list(seen) + + +def normalize_market(market: dict[str, Any], event: dict[str, Any] | None = None) -> Market: + """One market: the outcome Novig lists first as YES, the other as NO, + each under the venue's name for it. The title is the event, then the + market.""" + market_id = str(market["marketId"]) + yes, no = sides(market) + event = event or {} + event_status = event.get("status") + status, accepting = status_of(market.get("status"), event_status) + yes_quote, no_quote = quotes_of(market) + result, fmv = result_of(market) + kind, strike = market.get("marketType"), market.get("strike") + one, two = str(yes.get("name") or "Yes"), str(no.get("name") or "No") + starts = market.get("startsTs") or event.get("startsTs") + tags = tags_of(event) + title = " — ".join(str(p) for p in (event.get("description"), market.get("description")) if p) or market_id + void = "a push refunds every fill" if market.get("voids") == "PUSH" else "a void settles at fair market value" + fee = market.get("fee") or {} + return Market( + id=ids.qualify(VENUE, market_id), + venue=VENUE, + venue_market_id=market_id, + event_id=ids.qualify(VENUE, str(market["eventId"])) if market.get("eventId") else None, + title=title, + description=f"{kind or 'Market'}: {one} or {two}; {void}.", + slug=None, + yes=Outcome(label=one, quote=yes_quote, venue_token_id=str(yes["outcomeId"]), + info={"status": yes.get("status")}), + no=Outcome(label=two, quote=no_quote, venue_token_id=str(no["outcomeId"]), + info={"status": no.get("status")}), + status=status, # type: ignore[arg-type] + native_status=f"{market.get('status')}/{event_status}" if event_status else market.get("status"), + active=accepting, + open_timestamp=None, + open_datetime=None, + # Game markets trade on in play, so no close is known before the end. + close_timestamp=None, + close_datetime=None, + resolution_timestamp=market.get("settledTs"), + resolution_datetime=iso(market.get("settledTs")), + # The grid steps by 0.001 near the ends and 0.005 in the middle. + tick_size=None, + face_value=1.0, + book_model="shared_complement", + stats=MarketStats( + volume_total=contracts(market["volume"]) if market.get("volume") is not None else None, + open_interest=contracts(market["openInterest"]) if market.get("openInterest") is not None else None, + volume_unit="contracts", + ), + url=None, + image_url=None, + category=(tags or [None])[0], + tags=tags, + series_id=None, + outcome_label=one, + neg_risk=False, + settlement_sources=[], + info={ + "market": market, + "event": event or None, + "market_type": kind, + "strike": strike, + "voids": market.get("voids"), + "fee": fee or None, + "starts": starts, + "event_status": event_status, + "in_play": event_status == "OPEN_INGAME", + "yes_outcome_id": str(yes["outcomeId"]), + "no_outcome_id": str(no["outcomeId"]), + "result": result, + "fmv_yes": fmv, + "volume_qty": market.get("volume"), + "open_interest_qty": market.get("openInterest"), + "currency": "USD", + }, + ) + + +def normalize_event(event: dict[str, Any], markets: list[dict[str, Any]]) -> Event: + """One event (a game, or a futures question) with its markets.""" + event_id = str(event["eventId"]) + normalized = [normalize_market(m, event) for m in markets] + statuses = {m.status for m in normalized} + status = next((s for s in ("open", "closed", "settled") if s in statuses), None) + if status is None: + status, _ = status_of(None, event.get("status")) + status = "open" if event.get("status") in OPEN_EVENTS else status + tags = tags_of(event) + return Event( + id=ids.qualify(VENUE, event_id), + venue=VENUE, + venue_event_id=event_id, + title=str(event.get("description") or event_id), + description=None, + slug=None, + markets=normalized, + status=status, # type: ignore[arg-type] + native_status=event.get("status"), + category=(tags or [None])[0], + tags=tags, + series_id=None, + # A game's markets are separate questions (moneyline, spreads, totals). + mutually_exclusive=None, + close_timestamp=None, + close_datetime=None, + url=None, + image_url=None, + settlement_sources=[], + info={ + "sport": event.get("sport"), "league": event.get("league"), "starts": event.get("startsTs"), + "in_play": event.get("status") == "OPEN_INGAME", "event": event, + }, + ) + + +def book_levels(rows: Any, *, yes: bool) -> list[OrderLevel]: + """One outcome's resting orders, summed by price, as YES levels: on YES a + bid at its price, on NO an ask at one minus it.""" + by_price: dict[str, int] = {} + for row in rows or []: + if not isinstance(row, dict) or row.get("price") in (None, ""): + continue + qty = int(row.get("qty") or 0) + if qty > 0: + by_price[str(row["price"])] = by_price.get(str(row["price"]), 0) + qty + out = [] + for raw, qty in by_price.items(): + value = float(raw) + if 0 < value < 1: + out.append(OrderLevel(price=round(value if yes else 1 - value, 6), size=contracts(qty))) + return out + + +def normalize_order_book( + book: dict[str, Any], *, yes_outcome: str, no_outcome: str, market_id: str, side: BookSide = "yes", + depth: int | None = None, +) -> OrderBook: + """The market's two ladders read on YES; `side="no"` mirrors it. Sizes in + dollar contracts.""" + orders = book.get("orders") or {} + bids = sorted(book_levels(orders.get(yes_outcome), yes=True), key=lambda level: level.price, reverse=True) + asks = sorted(book_levels(orders.get(no_outcome), yes=False), key=lambda level: level.price) + derived = side == "no" + if derived: + bids, asks = ( + [OrderLevel(price=round(1 - level.price, 6), size=level.size) for level in asks], + [OrderLevel(price=round(1 - level.price, 6), size=level.size) for level in bids], + ) + elif side != "yes": + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + if depth: + bids, asks = bids[:depth], asks[:depth] + return OrderBook( + market_id=market_id, side=side, venue=VENUE, bids=bids, asks=asks, + timestamp=None, datetime=None, book_model="shared_complement", derived=derived, + # The route serves at most twenty price levels per outcome. + depth_scope="top_n", + info={"seq": book.get("seq"), "yes_outcome_id": yes_outcome, "no_outcome_id": no_outcome, + "orders": orders}, + ) + + +def normalize_trade(row: dict[str, Any], *, yes_outcome: str, market_id: str) -> Trade: + """One execution from the tape, in the YES price with the taker's side. + + The tape names the resting order's outcome and price (`tradeId` is the + resting order's fill), so the taker bought the other outcome at one minus + it: a print on YES at `p` is a taker selling YES at `p`, a print on NO at + `q` a taker buying YES at `1 - q`.""" + paid = float(row.get("price") or 0) + on_yes = str(row.get("outcomeId")) == yes_outcome + stamp = int(row.get("ts") or 0) + return Trade( + id=str(row.get("tradeId") or row.get("takeId") or stamp), + market_id=market_id, + timestamp=stamp, + datetime=iso(stamp) or "", + price=round(paid if on_yes else 1 - paid, 6), + quantity=contracts(row.get("qty")), + side="sell" if on_yes else "buy", + info=row, + ) + + +def candles_from_trades(trades: list[Trade], *, interval_seconds: int) -> list[Candle]: + """Prints bucketed into bars, oldest first, with their volume in contracts.""" + buckets: dict[int, list[Trade]] = {} + for trade in trades: + buckets.setdefault(trade.timestamp // 1000 // interval_seconds * interval_seconds, []).append(trade) + candles = [] + for start in sorted(buckets): + rows = sorted(buckets[start], key=lambda t: t.timestamp) + prices = [t.price for t in rows] + candles.append(Candle( + timestamp=start * 1000, datetime=iso(start * 1000) or "", + open=prices[0], high=max(prices), low=min(prices), close=prices[-1], + volume=round(sum(t.quantity for t in rows), 6), trade_count=len(rows), price_source="trade", + info={}, + )) + return candles + + +def fee_schedule_of(market: dict[str, Any]) -> FeeSchedule: + """The market's own schedule: a taker pays `c * P * (1 - P)` per dollar + contract, a maker earns `makerCredit` of that back (a negative maker + rate). `charged` says when: `WHEN_LIVE` (every game market) only while + the event is in play, `ALWAYS` in every phase. The rates are the in-play + ones, so before kickoff on a game market the estimate is an upper bound: + a fill matched pregame pays nothing.""" + fee = market.get("fee") or {} + coefficient = float(fee.get("coefficient") or 0) + credit = float(fee.get("makerCredit") or 0) + return FeeSchedule( + venue=VENUE, scope="market", scope_id=str(market.get("marketId") or ""), fee_type="quadratic_theta", + taker_rate=coefficient, maker_rate=-round(coefficient * credit, 9), exponent=1.0, + info={"charged": fee.get("charged"), "coefficient": fee.get("coefficient"), + "maker_credit": fee.get("makerCredit"), "currency": "USD", + "rounding": "half up to $0.00001, no minimum"}, + ) + + +def error_code(body: Any) -> str | None: + """Novig's stable error code: `{"code": "...", "message": "..."}`.""" + if isinstance(body, dict) and body.get("code"): + return str(body["code"]) + return None + + +def _words_in(text: str, query: str) -> bool: + words = [w for w in query.lower().split() if w] + haystack = text.lower() + return bool(words) and all(w in haystack for w in words) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class Novig(Exchange): + """Novig, read-only. No key needed. + + ```python + import synpath + + nv = synpath.Novig() # Production; Novig(env="paper") for Paper + markets = nv.fetch_markets(league="NFL", limit=10) + book = nv.fetch_order_book(markets[0].id) + ``` + """ + + id = VENUE + name = "Novig" + book_model = "shared_complement" + has: dict[str, Capability] = { + "fetch_markets": True, + "fetch_events": True, + "fetch_market": True, + # One request each: the catalog has no batch read by id. + "fetch_markets_by_ids": True, + # The catalog lists newest first and has no sort. + "sort": False, + "fetch_order_book": True, + # One request per market. + "fetch_order_books": True, + "fetch_trades": True, + # Built from the public tape; the venue's own candles need a key. + "fetch_ohlcv": "partial", + "fetch_series": False, + "fetch_fee_schedule": True, + # Words matched against the open events' descriptions here: the + # venue has no search route. + "search": "partial", + "match_market": False, + "match_event": False, + } + + def __init__( + self, *, env: str | None = None, api_url: str | None = None, credentials: Any = None, + timeout: float = 30.0, limiter: RateLimiter | None = LIMITER, client: Any = None, + signed_client: Any = None, + ): + """No argument is needed: the catalog is public. `env` picks + Production (the default) or Paper, whose ids differ. `credentials` + (a `NovigCredentials`) signs the history reads that `fetch_market` + falls back to for a market no longer in the open catalog.""" + env = env or getattr(credentials, "env", None) or "production" + if env not in API_URLS: + raise BadRequest(f"{VENUE}: env must be production or paper, got {env!r}") + self.env = env + self.api_url = (api_url or API_URLS[env]).rstrip("/") + if client is None: + client = httpx.Client(timeout=timeout, follow_redirects=True, headers={"Accept": "application/json"}) + self.http = HttpClient(self.api_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + self.signed: HttpClient | None = None + key_id = getattr(credentials, "key_id", None) or getattr(credentials, "management_key_id", None) + pem = getattr(credentials, "private_key_pem", None) if getattr(credentials, "key_id", None) \ + else getattr(credentials, "management_private_key_pem", None) + if key_id and pem: + from .trading.novig_signing import NovigAuth, NovigSigner + + auth = NovigAuth(NovigSigner(key_id, pem)) + if signed_client is None: + signed_client = httpx.Client(timeout=timeout, follow_redirects=True, + headers={"Accept": "application/json"}) + signed_client.auth = auth + self.signed = HttpClient(self.api_url, limiter=limiter, timeout=timeout, client=signed_client, venue=VENUE) + self._sides: dict[str, tuple[str, str]] = {} + self._events: dict[str, dict[str, Any]] = {} + self._events_at = 0.0 + self._lock = threading.Lock() + + def _get(self, path: str, params: Any = None) -> Any: + return self.http.get(f"/v3/public/catalog{path}", params) + + # -- raw reads ---------------------------------------------------------- + + def fetch_novig_market(self, market_id: str) -> dict[str, Any]: + """One market as the venue answers it: the open catalog, else (with a + key) market history, which keeps every market.""" + native = self.native(market_id) + try: + market = self._get(f"/markets/{native}") + except MarketNotFound: + if self.signed is None: + raise MarketNotFound( + f"{VENUE}: no open market {native} (a key reads closed and settled ones from history)" + ) from None + market = self.signed.get(f"/v3/history/markets/{native}") + if isinstance(market, dict): + self.remember(market) + return market + + def fetch_novig_event(self, event_id: str) -> dict[str, Any] | None: + native = self.native(event_id) + try: + return self._get(f"/events/{native}") + except MarketNotFound: + if self.signed is None: + return None + try: + return self.signed.get(f"/v3/history/events/{native}") + except MarketNotFound: + return None + + def outcome_ids(self, market_id: str) -> tuple[str, str]: + """(YES outcome id, NO outcome id) of a market, read once and kept: + a market's outcomes never change.""" + native = self.native(market_id) + with self._lock: + known = self._sides.get(native) + if known is None: + self.fetch_novig_market(native) + known = self._sides[native] + return known + + def remember(self, market: dict[str, Any]) -> None: + """Keep a market's outcome ids from a payload already in hand.""" + if market.get("marketId") and len(market.get("outcomes") or []) == 2: + with self._lock: + self._sides[str(market["marketId"])] = side_ids(market) + + def _events_by_id(self, event_ids: list[str]) -> dict[str, dict[str, Any]]: + """The open events, read in one listing and reused for `EVENTS_TTL` + seconds; an id not in it is read on its own.""" + wanted = list(dict.fromkeys(e for e in event_ids if e)) + stale = time.monotonic() - self._events_at > EVENTS_TTL + missing = [e for e in wanted if stale or e not in self._events] + if missing: + if len(missing) > 3: + fresh: dict[str, dict[str, Any]] = {} + after = None + for _ in range(MAX_PAGES): + body = self._get("/events", {"limit": PAGE, "after": after}) or {} + for event in body.get("items") or []: + fresh[str(event["eventId"])] = event + after = body.get("next") + if not after or not body.get("items"): + break + self._events, self._events_at = fresh, time.monotonic() + missing = [e for e in wanted if e not in self._events] + for event_id in missing: + event = self.fetch_novig_event(event_id) + if event: + self._events[event_id] = event + return {e: self._events[e] for e in wanted if e in self._events} + + # -- catalog ------------------------------------------------------------ + + def _statuses(self, status: str) -> tuple[str, ...]: + check_status(status) + if status not in EVENT_STATUSES: + raise NotSupported( + f"{VENUE}: the public catalog holds markets until they settle; it lists open and closed ones " + f"(status='open', 'closed' or 'all'). Read a settled market by id with fetch_market and a key" + ) + return EVENT_STATUSES[status] + + def _normalized(self, rows: list[dict[str, Any]]) -> list[Market]: + events = self._events_by_id([str(m.get("eventId")) for m in rows]) + out = [] + for row in rows: + if len(row.get("outcomes") or []) != 2: + continue + self.remember(row) + out.append(normalize_market(row, events.get(str(row.get("eventId"))))) + return out + + def fetch_markets( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", sort: str | None = None, league: str | list[str] | None = None, + market_type: str | list[str] | None = None, event: str | None = None, + ) -> Page[Market]: + """One page of markets, newest first, with their top of book. `status` + is the events' state: open (pregame, in play, delayed), closed + (final or cancelled, awaiting their grades) or all; a market leaves + the public catalog once it settles. `league` (`NFL`, `NBA`) + and `market_type` (`MONEY`, `SPREAD`, `TOTAL`) are the venue's + canonical names; `event` one event's markets. `query` matches words + against the open events' descriptions ("Chiefs", "Bills") and returns + every market of the events that match.""" + statuses = self._statuses(status) + check_sort(sort, venue=VENUE, supported=False) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + params = {"league": _joined(league), "marketType": _joined(market_type), + "eventStatus": ",".join(statuses) or None} + if query: + rows: list[dict[str, Any]] = [] + for event_id in self._search(query, statuses): + body = self._get("/markets", {**params, "event": event_id, "limit": PAGE}) or {} + rows += body.get("items") or [] + if len(rows) >= wanted: + break + return Page(self._normalized(rows[:wanted]), next_cursor=None) + body = self._get("/markets", {**params, "event": event and self.native(event), "limit": wanted, + "after": cursor}) or {} + return Page(self._normalized(list(body.get("items") or [])), next_cursor=body.get("next") or None) + + def _search(self, query: str, statuses: tuple[str, ...]) -> list[str]: + if not query.strip(): + raise BadRequest(f"{VENUE}: an empty search") + matched = [] + after = None + for _ in range(MAX_PAGES): + body = self._get("/events", {"status": ",".join(statuses) or None, "limit": PAGE, "after": after}) or {} + matched += [str(e["eventId"]) for e in body.get("items") or [] + if _words_in(str(e.get("description") or ""), query)] + after = body.get("next") + if not after or not body.get("items"): + break + return matched + + def fetch_events( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", league: str | list[str] | None = None, + ) -> Page[Event]: + """One page of events (twenty unless `limit` says), each with its + markets: one request for the page, then one per event. Filters as + `fetch_markets`.""" + statuses = self._statuses(status) + wanted = page_limit(limit) or EVENT_PAGE + params = {"status": ",".join(statuses) or None, "league": _joined(league)} + if query: + found = set(self._search(query, statuses)) + body = {"items": [e for e in self._events_by_id(sorted(found)).values()]} + following = None + else: + body = self._get("/events", {**params, "limit": wanted, "after": cursor}) or {} + following = body.get("next") or None + events = list(body.get("items") or [])[:wanted] + out = [] + for event in events: + markets = self._event_markets(str(event["eventId"])) + out.append(normalize_event(event, markets)) + return Page(out, next_cursor=following) + + def _event_markets(self, event_id: str) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + after = None + for _ in range(MAX_PAGES): + body = self._get("/markets", {"event": event_id, "limit": PAGE, "after": after}) or {} + rows += [m for m in body.get("items") or [] if len(m.get("outcomes") or []) == 2] + after = body.get("next") + if not after or not body.get("items"): + break + for row in rows: + self.remember(row) + return rows + + def fetch_event(self, event_id: str) -> Event: + """One event with its markets, by `novig:`.""" + native = self.native(event_id) + event = self.fetch_novig_event(native) + if not event: + raise MarketNotFound(f"{VENUE}: no event {native}") + return normalize_event(event, self._event_markets(native)) + + def fetch_market(self, market_id: str) -> Market: + """One market by id: from the open catalog, else (with a key) from + history, which keeps settled markets and their grades.""" + market = self.fetch_novig_market(market_id) + event = self._events_by_id([str(market.get("eventId") or "")]).get(str(market.get("eventId") or "")) + return normalize_market(market, event) + + def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: + """Markets in the order asked for, one request each; ids the venue + does not know are left out.""" + rows = [] + for market_id in dict.fromkeys(market_ids): + try: + rows.append(self.fetch_novig_market(market_id)) + except MarketNotFound: + continue + return self._normalized(rows) + + # -- market data -------------------------------------------------------- + + def fetch_order_book(self, market_id: str, *, side: BookSide = "yes", depth: int | None = None) -> OrderBook: + """Both outcomes' ladders read on YES, up to twenty price levels each.""" + native = self.native(market_id) + yes, no = self.outcome_ids(native) + levels = min(depth, BOOK_DEPTH) if depth else None + book = self._get(f"/markets/{native}/book", {"depth": levels}) or {} + return normalize_order_book(book, yes_outcome=yes, no_outcome=no, market_id=self.qualify(native), + side=side, depth=depth) + + def fetch_order_books( + self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None, + ) -> dict[str, OrderBook]: + """Books for many markets, one request each (and one more for a + market whose outcomes are not yet known).""" + out: dict[str, OrderBook] = {} + for market_id in dict.fromkeys(market_ids): + try: + book = self.fetch_order_book(market_id, side=side, depth=depth) + except MarketNotFound: + continue + out[book.market_id] = book + return out + + def _tape(self, native: str, *, limit: int, after: str | None) -> tuple[list[dict[str, Any]], str | None]: + body = self._get(f"/markets/{native}/trades", {"limit": limit, "after": after}) or {} + return list(body.get("items") or []), body.get("next") or None + + def fetch_trades( + self, market_id: str, *, since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Trade]: + """The market's tape, a page newest first from the venue and returned + oldest first, in the YES price with the taker's side.""" + native = self.native(market_id) + yes, _ = self.outcome_ids(native) + rows, following = self._tape(native, limit=page_limit(limit) or MAX_PAGE_LIMIT, after=cursor) + trades = [normalize_trade(row, yes_outcome=yes, market_id=self.qualify(native)) for row in rows] + if since: + trades = [t for t in trades if t.timestamp >= since] + return Page(sorted(trades, key=lambda t: t.timestamp), next_cursor=following) + + def fetch_ohlcv( + self, market_id: str, *, timeframe: str = "1h", since: int | None = None, until: int | None = None, + limit: int | None = None, + ) -> list[Candle]: + """Bars in the YES price built from the public tape, with their volume + in contracts (`price_source="trade"`). The tape is read back page by + page until `since`, at most ten pages of 500.""" + native = self.native(market_id) + yes, _ = self.outcome_ids(native) + seconds = timeframe_seconds(timeframe) + trades: list[Trade] = [] + after = None + for _ in range(MAX_TRADE_PAGES): + rows, after = self._tape(native, limit=500, after=after) + batch = [normalize_trade(r, yes_outcome=yes, market_id=self.qualify(native)) for r in rows] + trades += batch + if not after or (since and batch and min(t.timestamp for t in batch) < since): + break + candles = candles_from_trades(trades, interval_seconds=seconds) + if since: + candles = [c for c in candles if c.timestamp + seconds * 1000 > since] + if until: + candles = [c for c in candles if c.timestamp <= until] + return pick_bars(candles, since=since, limit=limit) + + # -- reference ---------------------------------------------------------- + + def fetch_fee_schedule(self, market_id: str) -> FeeSchedule: + """The market's own fee: coefficient, maker credit, and when it is charged.""" + return fee_schedule_of(self.fetch_novig_market(market_id)) + + def close(self) -> None: + self.http.close() + if self.signed is not None: + self.signed.close() + + +def _joined(value: str | list[str] | None) -> str | None: + if value is None: + return None + return value if isinstance(value, str) else ",".join(value) + + +def fee_estimate(schedule: FeeSchedule, yes_price: Decimal, quantity: Decimal) -> tuple[Decimal, Decimal]: + """(taker fee, maker fee) for `quantity` dollar contracts at a YES price. + Symmetric in the price, so either outcome gives the same fee.""" + taker = schedule.estimate(float(yes_price), float(quantity), taker=True) or 0.0 + maker = schedule.estimate(float(yes_price), float(quantity), taker=False) or 0.0 + return Decimal(str(round(taker, 5))), Decimal(str(round(maker, 5))) diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 6743665..495fac3 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -35,7 +35,7 @@ log = logging.getLogger("synpath.serve") -VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet") +VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "novig") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index aad1025..98b9e78 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -81,12 +81,15 @@ def __getattr__(name: str): if name == "SxBetTrading": from .sx_bet import SxBetTrading return SxBetTrading + if name == "NovigTrading": + from .novig import NovigTrading + return NovigTrading raise AttributeError(name) __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index ab2f240..e6d69d3 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -29,6 +29,14 @@ def sx_bet_identity(creds: Any) -> str: return f"{wallet}; API key: market data, account, cancels; {placing}" +def novig_identity(creds: Any) -> str: + """Which Novig keys loaded: the subaccount's trading key trades; the + management key opens and funds subaccounts.""" + trading = f"trading key {creds.key_id}" if creds.key_id else "no trading key: set NOVIG_KEY_ID to trade" + management = f", management key {creds.management_key_id}" if creds.management_key_id else "" + return trading + management + + def doctor(dotenv: str | None) -> int: logging.basicConfig(level=logging.INFO, format="%(message)s") try: @@ -52,11 +60,13 @@ def doctor(dotenv: str | None) -> int: continue if creds is None: names = ENV_NAMES[venue] - hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair") else "" + hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "novig") else "" rows.append((venue, "not configured", f"set {names[0]}{hint}")) continue stage = getattr(creds, "env", "-") - if getattr(creds, "key_id", None): + if venue == "novig": + identity = novig_identity(creds) + elif getattr(creds, "key_id", None): identity = f"key id {creds.key_id}" elif getattr(creds, "participant_id", None): identity = f"participant {creds.participant_id}" diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..a5a7f1e 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -45,6 +45,10 @@ (certificate login) and BETFAIR_JURISDICTION Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market (market data) data needs no login; one gives undelayed quotes + Novig NOVIG_KEY_ID, NOVIG_PRIVATE_KEY_PATH (a subaccount's trading + key, PEM, Ed25519 or P-256), NOVIG_ENV (production, the + default, or paper), and optionally NOVIG_MANAGEMENT_KEY_ID / + NOVIG_MANAGEMENT_PRIVATE_KEY_PATH (subaccounts and funding) """ from __future__ import annotations @@ -357,10 +361,38 @@ def secrets(self) -> list[str]: return [self.password] +@dataclass(frozen=True, repr=False) +class NovigCredentials: + """Novig: a subaccount's trading key, and optionally the account's + management key. Each is a key id and a PKCS#8 PEM private key, Ed25519 + or P-256, that signs every request (NOVIG-V3). + + The trading key places and cancels orders on its one subaccount, reads + its orders, fills, positions and balance, and opens the websocket. The + management key (Profile -> Settings -> Novig API in the web app) opens, + funds and labels subaccounts and never places an order. A key works only + in the environment it was made in.""" + + key_id: str | None = None + """The trading key's id: also the address of its subaccount.""" + private_key_pem: bytes | None = field(default=None, repr=False) + management_key_id: str | None = None + management_private_key_pem: bytes | None = field(default=None, repr=False) + env: Literal["production", "paper"] = "production" + """`production` trades real money at api.novig.com (US identity and + location checks); `paper` play money at api.paper.novig.com.""" + _public = ("key_id", "management_key_id", "env") + __repr__ = _redacted_repr + + @property + def secrets(self) -> list[str]: + return [pem.decode(errors="ignore") for pem in (self.private_key_pem, self.management_private_key_pem) if pem] + + Credentials = ( KalshiCredentials | PolymarketCredentials | PolymarketUSCredentials | PolymarketUSExchangeCredentials | OpinionCredentials | HyperliquidCredentials | PredictFunCredentials | LimitlessCredentials | SxBetCredentials - | BetfairCredentials | SmarketsCredentials + | BetfairCredentials | SmarketsCredentials | NovigCredentials ) ENV_NAMES: dict[str, tuple[str, ...]] = { @@ -389,6 +421,10 @@ def secrets(self) -> list[str]: "BETFAIR_JURISDICTION", ), "smarkets": ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"), + "novig": ( + "NOVIG_KEY_ID", "NOVIG_PRIVATE_KEY_PATH", "NOVIG_ENV", "NOVIG_MANAGEMENT_KEY_ID", + "NOVIG_MANAGEMENT_PRIVATE_KEY_PATH", + ), } """What each venue reads. The first entry is the one whose absence means "not configured"; the rest are optional or have defaults.""" @@ -685,6 +721,38 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: return SmarketsCredentials(username=username, password=password) +NOVIG_ENVS = ("production", "paper") + + +def load_novig(env: Mapping[str, str]) -> NovigCredentials | None: + key_id, management_id = env.get("NOVIG_KEY_ID") or None, env.get("NOVIG_MANAGEMENT_KEY_ID") or None + if not key_id and not management_id: + return None + pems: list[bytes | None] = [] + for name, path_var in ((key_id, "NOVIG_PRIVATE_KEY_PATH"), (management_id, "NOVIG_MANAGEMENT_PRIVATE_KEY_PATH")): + path = env.get(path_var) or None + if name and not path: + id_var = "NOVIG_KEY_ID" if path_var == "NOVIG_PRIVATE_KEY_PATH" else "NOVIG_MANAGEMENT_KEY_ID" + raise CredentialsMissing(f"{id_var} is set but {path_var} is not") + pem = _read_pem(path, var=path_var) if name and path else None + if pem is not None: + from .novig_signing import load_private_key + + try: + load_private_key(pem) + except (ValueError, TypeError) as exc: + raise CredentialsMissing(f"{path_var} points at {path!r}, which is not an unencrypted " + f"Ed25519 or P-256 PKCS#8 key: {exc}") from None + pems.append(pem) + stage = (env.get("NOVIG_ENV") or "production").strip().lower() + if stage not in NOVIG_ENVS: + raise CredentialsMissing(f"NOVIG_ENV must be production or paper, got {stage!r}") + return NovigCredentials( + key_id=key_id, private_key_pem=pems[0], management_key_id=management_id, + management_private_key_pem=pems[1], env=stage, # type: ignore[arg-type] + ) + + LOADERS = { "kalshi": load_kalshi, "polymarket": load_polymarket, @@ -697,6 +765,7 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: "sx_bet": load_sx_bet, "betfair": load_betfair, "smarkets": load_smarkets, + "novig": load_novig, } diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..a870b73 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -71,7 +71,7 @@ def _pem_file(raw: str) -> str | None: return "The path to the private key file is required." path = Path(raw).expanduser() if not path.is_file(): - return f"No file at {path}. Give the full path to the key file Kalshi downloaded." + return f"No file at {path}. Give the full path to the key file the venue gave you." try: head = path.read_bytes()[:4096] except OSError as exc: @@ -278,6 +278,31 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} +def ask_novig(p: Prompter) -> dict[str, str]: + p.say("\nNovig: market data is public and needs nothing; this is for trading. Trading uses a subaccount's") + p.say("trading key: make the management key in the web app (Profile -> Settings -> Novig API; paper.novig.com") + p.say("for Paper), then open a subaccount with it, which issues the trading key. Each key is a key id and a") + p.say("PEM private key file (Ed25519 or P-256). Production needs US identity and location checks.") + env = p.value("Environment, production (real money) or paper", check=_choice("production", "paper"), + default="production") + values = {"NOVIG_ENV": env} + key_id = p.value("Trading key id (empty if you have only the management key so far)", default="", + check=lambda raw: None) + if key_id: + values["NOVIG_KEY_ID"] = key_id + values["NOVIG_PRIVATE_KEY_PATH"] = str(Path(p.value("Path to its private key file (.pem)", + check=_pem_file)).expanduser().resolve()) + management = p.value("Management key id (optional: opens and funds subaccounts; empty to skip)", default="", + check=lambda raw: None if raw or key_id else "Give a trading key or the management key.") + if management: + values["NOVIG_MANAGEMENT_KEY_ID"] = management + values["NOVIG_MANAGEMENT_PRIVATE_KEY_PATH"] = str(Path(p.value( + "Path to the management key file (novig-api-key-.pem)", check=_pem_file)).expanduser().resolve()) + if env == "production": + p.say(" production trades real money.") + return values + + VENUES: list[tuple[str, tuple[str, ...], Callable[[Prompter], dict[str, str]]]] = [ ("Kalshi", ("KALSHI_KEY_ID",), ask_kalshi), ("Polymarket", ("POLYMARKET_PRIVATE_KEY",), ask_polymarket), @@ -289,6 +314,7 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), + ("Novig", ("NOVIG_KEY_ID", "NOVIG_MANAGEMENT_KEY_ID"), ask_novig), ] """Each venue, the settings any of which means it is set up already (an API key alone is, for market data), and the questions.""" diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 509a8fd..706a8dd 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -66,6 +66,12 @@ # £0.05. "smarkets": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.0001"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("0.05")}, + # Prices on a grid of 0.001 near the ends (to 0.050, from 0.950) and + # 0.005 between, so the tick here is the finest and an order is snapped + # onto the grid on the cheaper side. The venue's contract pays 1 cent, so + # dollar contracts step by 0.01. + "novig": {"default_tick": Decimal("0.001"), "min_quantity": Decimal("0.01"), "quantity_step": Decimal("0.01"), + "whole": False}, } diff --git a/synpath/trading/novig.py b/synpath/trading/novig.py new file mode 100644 index 0000000..aa98561 --- /dev/null +++ b/synpath/trading/novig.py @@ -0,0 +1,766 @@ +"""Novig order entry, and the venue's order, fill and position records on the YES leg. + +Built to Novig's v3 documentation and OpenAPI document. Every request is +signed (NOVIG-V3, Ed25519 or P-256; see `novig_signing`), and the signer is +checked against the venue's thirty published vectors. + +**Two keys.** A subaccount's *trading* key places and cancels orders on that +subaccount and reads its orders, fills, positions and balance; it is what +this adapter trades with (`NOVIG_KEY_ID`, `NOVIG_PRIVATE_KEY_PATH`). The +account's *management* key (made in the web app under Profile -> Settings -> +Novig API) opens, funds and labels subaccounts and issues their trading keys, +and never places an order; the subaccount methods here use it when it is +configured (`NOVIG_MANAGEMENT_KEY_ID`, `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH`). + +**An order buys one outcome.** `buy` buys the market's YES outcome (the one +Novig lists first) at the price given; `sell` buys the NO outcome at one minus +it. Prices are snapped onto the venue's grid on the cheaper side (0.001 steps +near the ends, 0.005 in the middle), so an order never pays more than asked. +Sizes are dollar contracts in steps of 0.01: the venue counts 1-cent +contracts, 100 to this library's one. + +**Placement is queued.** The venue answers a placement with the order id +before the order reaches the book; the order is `pending` until the private +stream (or a read) says it rests, filled, or was rejected. Limits are `GTC`, +or `GTT` with `gtd` until `expires_at`; post-only is `PO`. A market order, or +an IOC limit, is `IOC` at the price given as the worst accepted; `fok` is +`FOK`. A batch placement is all or nothing. + +**Every resting order on an event is voided when it goes in play**, and again +each time play resumes after a stoppage. + +**Fees** are charged to the taker per fill, `c * P * (1 - P)` per contract, +and only while the event is in play on a game market; a maker earns a share +of it as a credit. `fetch_my_trades` reports each fill's fee. + +**Positions** are held per outcome, so a market's position carries both +inventories and nets them on the YES leg. + +Production trades real money and requires identity verification and a +location check (Arizona, Michigan and Nevada are restricted); Paper is the +same API with play money. +""" +from __future__ import annotations + +import asyncio +import time +import uuid +from decimal import Decimal +from typing import Any + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound +from ..types import Page +from . import novig_signing as sig +from .base import TradingExchange +from .credentials import NovigCredentials +from .errors import ( + CredentialsMissing, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, PermissionDenied, +) +from .limiter import BudgetLimiter, Priority +from .types import ( + Account, Balance, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, + PositionSide, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "novig" +API_URLS = {"production": "https://api.novig.com", "paper": "https://api.paper.novig.com"} + +BATCH = 256 +"""Orders one batch placement or cancel takes.""" + +PAGE = 500 +MAX_PAGES = 50 + +CLIENT_ID_NAMESPACE = uuid.UUID("6b1d3c5e-0f5a-4c47-9d0b-6e6f76696701") +"""A `client_order_id` that is not a UUID is sent as a UUID derived from it +under this namespace, so the same id always maps to the same `clientId`.""" + +STATUS = { + "PENDING": OrderStatus.PENDING, + "OPEN": OrderStatus.OPEN, + "FILLED": OrderStatus.CLOSED, + "CANCELED": OrderStatus.CANCELED, + "REJECTED": OrderStatus.REJECTED, +} +"""The venue's five order states. A partly filled order stays `OPEN`.""" + +STATUS_FILTER = { + OrderStatus.PENDING: "PENDING", OrderStatus.OPEN: "OPEN", OrderStatus.CLOSED: "FILLED", + OrderStatus.CANCELED: "CANCELED", OrderStatus.REJECTED: "REJECTED", +} + +TIME_IN_FORCE = {"GTC": TimeInForce.GTC, "GTT": TimeInForce.GTD, "IOC": TimeInForce.IOC, "FOK": TimeInForce.FOK, + "PO": TimeInForce.GTC} + +ONE = Decimal("1") + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return Decimal("0") + return Decimal(str(value)) + + +def yes_price(price: Any, on_yes: bool) -> Decimal: + """An outcome's price as the YES price.""" + return D(price) if on_yes else ONE - D(price) + + +def side_of(on_yes: bool) -> Side: + return Side.BUY if on_yes else Side.SELL + + +def client_id(client_order_id: str | None) -> str | None: + """The venue's `clientId` (a UUID) for a client order id.""" + if not client_order_id: + return None + try: + return str(uuid.UUID(client_order_id)) + except ValueError: + return str(uuid.uuid5(CLIENT_ID_NAMESPACE, client_order_id)) + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def order_of_row(row: dict[str, Any], *, yes_outcome: str, account: Account | None = None, + client_order_id: str | None = None) -> Order: + """One order as the venue reports it: an order record (`GET /v3/orders`), + or a resting order (`GET /v3/account/orders`, the stream's `open`), whose + `qty` is what rests.""" + on_yes = str(row.get("outcomeId")) == yes_outcome + native = str(row.get("status") or "OPEN") + status = STATUS.get(native, OrderStatus.OPEN) + quantity = sig.contracts_of(row.get("qty")) + remaining = sig.contracts_of(row["remaining"]) if row.get("remaining") is not None else ( + quantity if status in (OrderStatus.OPEN, OrderStatus.PENDING) else Decimal("0")) + if status == OrderStatus.CLOSED: + remaining = Decimal("0") + tif = str(row.get("tif") or "GTC") + expires = row.get("expiresTs") or row.get("expiresAt") + return Order( + id=str(row.get("orderId") or ""), + client_order_id=client_order_id or row.get("clientId") or None, + venue=VENUE, + account=account, + market_id=ids.qualify(VENUE, str(row.get("marketId") or "")), + side=side_of(on_yes), + type=OrderType.LIMIT, + time_in_force=TIME_IN_FORCE.get(tif, TimeInForce.GTC), + status=status, + price=yes_price(row.get("price"), on_yes), + quantity=quantity, + filled=max(Decimal("0"), quantity - remaining), + remaining=remaining, + post_only=tif == "PO", + expires_at=int(expires) if expires else None, + created_at=row.get("createdTs"), + info=row, + ) + + +def fill_of_row(row: dict[str, Any], *, yes_outcome: str, market_id: str | None = None, + account: Account | None = None) -> Fill: + """One fill: from `GET /v3/portfolio/fills`, whose price is + `100 * cost / qty` and whose `fee` is present only on a charged fill, or + from the stream's `fill` event, which carries the price and no fee.""" + on_yes = str(row.get("outcomeId")) == yes_outcome + qty = int(row.get("qty") or 0) + if row.get("price") not in (None, ""): + paid = D(row["price"]) + else: + paid = (D(row.get("cost")) * sig.QTY_PER_CONTRACT / qty) if qty else Decimal("0") + fee = D(row["fee"]) if row.get("fee") not in (None, "") else (Decimal("0") if "cost" in row else None) + taker = row.get("taker") + return Fill( + id=str(row.get("fillId") or ""), + order_id=str(row.get("orderId") or ""), + client_order_id=row.get("clientId") or None, + venue=VENUE, + account=account, + market_id=ids.qualify(VENUE, str(row.get("marketId") or market_id or "")), + side=side_of(on_yes), + price=yes_price(paid, on_yes), + quantity=sig.contracts_of(qty), + fee=fee, + fee_currency="USD", + liquidity=Liquidity.TAKER if taker is True else Liquidity.MAKER if taker is False else Liquidity.UNKNOWN, + timestamp=int(row.get("ts") or time.time() * 1000), + info=row, + ) + + +def position_of(market_id: str, rows: list[dict[str, Any]], *, yes_outcome: str, + account: Account | None = None) -> Position: + """A market's holdings of both outcomes, netted on the YES leg. `cost` is + what was paid for each; the entry price is given when one side is held.""" + held = {"yes": Decimal("0"), "no": Decimal("0")} + cost = {"yes": Decimal("0"), "no": Decimal("0")} + for row in rows: + key = "yes" if str(row.get("outcomeId")) == yes_outcome else "no" + held[key] += sig.contracts_of(row.get("qty")) + cost[key] += D(row.get("cost")) + net = held["yes"] - held["no"] + entry = None + if held["yes"] and not held["no"]: + entry = cost["yes"] / held["yes"] + elif held["no"] and not held["yes"]: + entry = ONE - cost["no"] / held["no"] + return Position( + venue=VENUE, + account=account, + market_id=ids.qualify(VENUE, market_id), + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), + inventory_yes=held["yes"], + inventory_no=held["no"], + entry_price=entry.quantize(Decimal("0.00001")) if entry is not None else None, + margin=cost["yes"] + cost["no"], + info={"rows": rows}, + ) + + +def error_of(exc: ExchangeError) -> ExchangeError: + """The venue's refusal as a typed error, by its stable `code`.""" + body = exc.body if isinstance(exc.body, dict) else {} + code = str(body.get("code") or "") + message = str(body.get("message") or exc) + text = f"{VENUE}: {message}" + (f" ({code})" if code else "") + if code in ("INSUFFICIENT_BALANCE", "WAGER_LIMIT_EXCEEDED", "POSITION_LIMIT_EXCEEDED"): + return InsufficientFunds(text, body=exc.body, status=exc.status) + if code in ("INVALID_PRICE", "QTY_OUT_OF_RANGE", "ORDER_TOO_SMALL", "ORDER_TOO_LARGE", "TTL_REQUIRED", + "TTL_NOT_ALLOWED", "TTL_OUT_OF_RANGE", "PRICE_BAND_VIOLATION", "INVALID_BODY", "INVALID_REQUEST"): + return InvalidOrder(text, body=exc.body, status=exc.status) + if code in ("MARKET_CLOSED", "MARKET_INACTIVE", "MARKET_LOCKED", "EVENT_LOCKED", "EVENT_NOT_TRADABLE", + "LEAGUE_LOCKED", "COMPETITOR_LOCKED", "NOT_LIVE_TRADABLE", "PAUSED", "SYSTEM_LOCKED", + "LIQUIDATION_ONLY"): + return MarketHalted(text, body=exc.body, status=exc.status) + if code == "ORDER_NOT_FOUND": + return OrderNotFound(text, body=exc.body, status=exc.status) + if code in ("OUTCOME_NOT_FOUND", "MARKET_NOT_FOUND", "EVENT_NOT_FOUND"): + return MarketNotFound(text, body=exc.body, status=exc.status) + if code == "SIGNATURE_REJECTED": + return AuthenticationError(f"{text}: check the key id, the key file, NOVIG_ENV and the clock", + body=exc.body, status=exc.status) + if exc.status in (403, 423, 451) or code in ("SCOPE_INSUFFICIENT", "KYC_REQUIRED", "SELF_EXCLUDED", + "ACCOUNT_LOCKED", "ACCOUNT_EXCLUDED", "EMPLOYEE_ACCOUNT"): + hint = "" + if exc.status == 451 or code.startswith("GEOLOCATION") or "REGION" in code or code == "ANONYMIZED_NETWORK": + hint = (": Production checks the request's location and the key holder's last device geolocation " + "(open the Novig app); Arizona, Michigan, Nevada and VPNs are refused") + return PermissionDenied(text + hint, body=exc.body, status=exc.status) + if code == "BATCH_REJECTED": + return OrderRejected(text, reason="batch_rejected", info=body, body=exc.body, status=exc.status) + return exc + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def venue_time_in_force(request: OrderRequest) -> str: + if request.type == OrderType.MARKET or request.time_in_force == TimeInForce.IOC: + return "IOC" + if request.time_in_force == TimeInForce.FOK: + return "FOK" + if request.post_only: + return "PO" + if request.time_in_force == TimeInForce.GTD: + return "GTT" + return "GTC" + + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; " + "submit it through the engine" + ) + if request.time_in_force == TimeInForce.DAY: + raise InvalidOrder(f"{VENUE}: day orders are the engine's; gtc, gtd, ioc or fok") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent IOC at the worst price you give") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: every order buys an outcome; there is no reduce-only order") + if request.post_only and (request.type == OrderType.MARKET + or request.time_in_force in (TimeInForce.IOC, TimeInForce.FOK)): + raise InvalidOrder(f"{VENUE}: a post-only order cannot also take at once") + if not Decimal("0") < D(request.price) < ONE: + raise InvalidOrder(f"{VENUE}: price {request.price} is outside (0, 1)") + + +def build_order( + request: OrderRequest, *, yes_outcome: str, no_outcome: str, now_ms: int | None = None, +) -> dict[str, Any]: + """The placement body for one request, before anything is sent: the + outcome bought, its price snapped down onto the grid, whole 1-cent + contracts, and the time in force.""" + check_request(request) + buying_yes = request.side == Side.BUY + paid = D(request.price) if buying_yes else ONE - D(request.price) + body: dict[str, Any] = { + "outcomeId": yes_outcome if buying_yes else no_outcome, + "price": sig.wire_price(sig.snap_down(paid)), + "qty": sig.qty_of(D(request.quantity)), + "tif": venue_time_in_force(request), + } + if request.time_in_force == TimeInForce.GTD: + clock = int(time.time() * 1000) if now_ms is None else now_ms + ttl = int(request.expires_at or 0) - clock + if ttl <= 0: + raise InvalidOrder(f"{VENUE}: expires_at is in the past") + body["ttl"] = ttl + identifier = client_id(request.client_order_id) + if identifier: + body["clientId"] = identifier + return body + + +def order_after_placing(request: OrderRequest, answer: dict[str, Any], body: dict[str, Any], *, + account: Account) -> Order: + """The order as placed: queued at the venue, not yet resting.""" + buying_yes = request.side == Side.BUY + quantity = sig.contracts_of(body["qty"]) + return Order( + id=str(answer.get("orderId") or ""), + client_order_id=request.client_order_id, + venue=VENUE, + account=request.account or account, + market_id=ids.qualify(VENUE, ids.native(VENUE, request.market_id)), + side=request.side, + type=request.type, + time_in_force=request.time_in_force, + status=OrderStatus.PENDING, + price=yes_price(body["price"], buying_yes), + quantity=quantity, + filled=Decimal("0"), + remaining=quantity, + post_only=request.post_only, + expires_at=request.expires_at if request.time_in_force == TimeInForce.GTD else None, + created_at=int(time.time() * 1000), + book=request.book, + trader=request.trader, + tags=request.tags, + info={"body": body, "answer": answer}, + ) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class NovigTrading(TradingExchange): + """Novig order entry, on one subaccount. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.novig import NovigTrading + + async with NovigTrading(require("novig", load_credentials())) as nv: + print(await nv.fetch_balance()) + ``` + """ + + id = VENUE + name = "Novig" + has: dict[str, Capability] = { + "create_order": True, + # Up to 256 a request, all or nothing. + "create_orders": True, + "cancel_order": True, + # Up to 256 a request; an unknown id does not stop the rest. + "cancel_orders": True, + "cancel_all_orders": True, + "edit_order": False, + "fetch_order": True, + "fetch_open_orders": True, + "fetch_orders": True, + "fetch_my_trades": True, + "fetch_positions": True, + "fetch_balance": True, + "fetch_settlements": False, + "fetch_queue_position": False, + "fetch_fee_estimate": True, + # Novig's parlay RFQ is for registered liquidity providers; not here. + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: NovigCredentials, + *, + account_name: str = "default", + url: str | None = None, + catalog: Any = None, + limiter: BudgetLimiter | None = None, + timeout: float = 30.0, + client: Any = None, + management_client: Any = None, + ): + self.credentials = credentials + self.env = credentials.env + self.url = (url or API_URLS[credentials.env]).rstrip("/") + self.signer = sig.NovigSigner(credentials.key_id, credentials.private_key_pem) \ + if credentials.key_id and credentials.private_key_pem else None + """The subaccount's trading key; `None` with a management key alone.""" + self.management = sig.NovigSigner(credentials.management_key_id, credentials.management_private_key_pem) \ + if credentials.management_key_id and credentials.management_private_key_pem else None + self.account = Account(venue=VENUE, name=account_name, subaccount=credentials.key_id) + # The venue's throttles: place 8 Hz, cancel and read 16 Hz. + self.limiter = limiter or BudgetLimiter(read_per_second=16, write_per_second=8) + self.http = self._client(client, self.signer, timeout) if self.signer else None + self.management_http = self._client(management_client, self.management, timeout) if self.management else None + self._own_catalog = catalog is None + if catalog is None: + from ..novig import Novig + + catalog = Novig(env=credentials.env, api_url=self.url, credentials=credentials) + self.catalog = catalog + """The read adapter: which outcome of a market is YES, and fee schedules.""" + + def _client(self, client: Any, signer: sig.NovigSigner, timeout: float) -> AsyncHttpClient: + import httpx + + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True, + headers={"Accept": "application/json"}) + shared.auth = sig.NovigAuth(signer) + return AsyncHttpClient(self.url, limiter=None, client=shared, venue=VENUE) + + # -- plumbing ------------------------------------------------------------- + + def _trading(self) -> AsyncHttpClient: + if self.http is None: + raise CredentialsMissing( + f"{VENUE}: trading needs a subaccount's trading key; set NOVIG_KEY_ID and NOVIG_PRIVATE_KEY_PATH " + "(open a subaccount with open_subaccount() and the management key to get one)" + ) + return self.http + + def _managing(self) -> AsyncHttpClient: + if self.management_http is None: + raise CredentialsMissing( + f"{VENUE}: subaccounts and funding need the management key (Profile -> Settings -> Novig API); " + "set NOVIG_MANAGEMENT_KEY_ID and NOVIG_MANAGEMENT_PRIVATE_KEY_PATH" + ) + return self.management_http + + async def _call( + self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, cost: float = 1, management: bool = False, + ) -> Any: + """One signed call, paced; errors mapped by the venue's code.""" + http = self._managing() if management else self._trading() + await self.limiter.acquire(cost=cost, kind=kind, priority=priority) # type: ignore[arg-type] + query = {k: v for k, v in (params or {}).items() if v is not None} + try: + return await http.request(method, path, params=query or None, json=body) + except ExchangeError as exc: + if exc.status == 404 and isinstance(exc.body, dict) and "notCanceled" in exc.body: + return exc.body + mapped = error_of(exc) + if mapped is exc and exc.status == 404 and path.startswith("/v3/orders/"): + raise OrderNotFound(f"{VENUE}: no order {path.rsplit('/', 1)[-1]}", body=exc.body, + status=exc.status) from None + raise mapped from None + + async def outcome_ids(self, market_id: str) -> tuple[str, str]: + """(YES outcome, NO outcome) of a market, from the catalog; kept.""" + return await asyncio.to_thread(self.catalog.outcome_ids, ids.native(VENUE, market_id)) + + async def _yes_of(self, market_ids: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for market_id in dict.fromkeys(m for m in market_ids if m): + out[market_id] = (await self.outcome_ids(market_id))[0] + return out + + async def _orders(self, rows: list[dict[str, Any]]) -> list[Order]: + yes = await self._yes_of([str(r.get("marketId") or "") for r in rows]) + return [order_of_row(r, yes_outcome=yes.get(str(r.get("marketId")), ""), account=self.account) for r in rows] + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest) -> Order: + """Place one order. The venue queues it: the result is `pending`.""" + yes, no = await self.outcome_ids(request.market_id) + body = build_order(request, yes_outcome=yes, no_outcome=no) + answer = await self._call("POST", "/v3/orders", body=body, kind="write") or {} + return order_after_placing(request, answer, body, account=self.account) + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many orders, 256 a request, one result each in the order asked for. + The venue places a batch all or nothing: when it refuses one order, + every other order of that batch comes back refused too, unplaced.""" + out: list[Order | Exception] = [] + for start in range(0, len(requests), BATCH): + chunk = requests[start:start + BATCH] + built: list[tuple[OrderRequest, dict[str, Any]] | Exception] = [] + for request in chunk: + try: + yes, no = await self.outcome_ids(request.market_id) + built.append((request, build_order(request, yes_outcome=yes, no_outcome=no))) + except (InvalidOrder, MarketNotFound) as exc: + built.append(exc) + ready = [b for b in built if not isinstance(b, Exception)] + answers: list[Any] = [] + refused: ExchangeError | None = None + if ready: + try: + answers = list(await self._call("POST", "/v3/orders/batch", + body={"orders": [b[1] for b in ready]}, + kind="write", cost=len(ready)) or []) + except ExchangeError as exc: + refused = exc + reasons: dict[int, str] = {} + if refused is not None and isinstance(refused.body, dict): + for item in refused.body.get("rejected") or []: + if isinstance(item, dict) and item.get("index") is not None: + reasons[int(item["index"])] = str(item.get("reason") or "refused") + position = 0 + for entry in built: + if isinstance(entry, Exception): + out.append(entry) + continue + request, body = entry + index, position = position, position + 1 + if refused is not None: + if index in reasons: + out.append(InvalidOrder(f"{VENUE}: {reasons[index]}", body=refused.body, + status=refused.status)) + else: + out.append(OrderRejected(f"{VENUE}: not placed: the batch was refused as a whole ({refused})", + reason="batch_rejected", body=refused.body, status=refused.status)) + continue + answer = answers[index] if index < len(answers) and isinstance(answers[index], dict) else {} + out.append(order_after_placing(request, answer, body, account=self.account)) + return out + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one order and read it back. The venue queues the cancel, so an + order still resting when read is reported `pending_cancel`.""" + await self._call("DELETE", f"/v3/orders/{order_id}", kind="write", priority=Priority.HIGH) + order = await self.fetch_order(order_id) + if order.status == OrderStatus.OPEN: + order = order.model_copy(update={"status": OrderStatus.PENDING_CANCEL}) + return order + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel up to 256 orders a request, then read each back. An order the + venue would not cancel (already filled, already cancelled, unknown) + comes back as it stands, or as `OrderNotFound`.""" + refused: dict[str, str] = {} + for start in range(0, len(order_ids), BATCH): + chunk = order_ids[start:start + BATCH] + raw = await self._call("DELETE", "/v3/orders/batch", body={"orderIds": chunk}, kind="write", + priority=Priority.HIGH, cost=len(chunk)) or {} + for item in raw.get("notCanceled") or []: + if isinstance(item, dict) and item.get("orderId"): + refused[str(item["orderId"])] = str(item.get("reason") or "refused") + out: list[Order | Exception] = [] + for order_id in order_ids: + if refused.get(order_id) == "NOT_FOUND": + out.append(OrderNotFound(f"{VENUE}: no order {order_id}")) + continue + try: + order = await self.fetch_order(order_id) + except OrderNotFound as exc: + out.append(exc) + continue + if order.status == OrderStatus.OPEN and order_id not in refused: + order = order.model_copy(update={"status": OrderStatus.PENDING_CANCEL}) + out.append(order) + return out + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int: + """Every resting order on the subaccount, or on one market. Returns how + many cancels the venue queued.""" + params = {"market": ids.native(VENUE, market_id)} if market_id else None + raw = await self._call("DELETE", "/v3/orders", params=params, kind="write", priority=Priority.HIGH) or {} + return int(raw.get("canceled") or 0) + + async def fetch_order(self, order_id: str) -> Order: + """One order by id, in any state. Right after placing, the venue can + answer not-found until the order reaches the book.""" + row = await self._call("GET", f"/v3/orders/{order_id}") + if not isinstance(row, dict) or not row.get("orderId"): + raise OrderNotFound(f"{VENUE}: no order {order_id}") + return (await self._orders([row]))[0] + + async def _paged(self, path: str, params: dict[str, Any], *, limit: int | None = None, + cursor: str | None = None, pages: int = MAX_PAGES) -> tuple[list[dict[str, Any]], str | None]: + rows: list[dict[str, Any]] = [] + after = cursor + for _ in range(pages): + raw = await self._call("GET", path, params={**params, "limit": limit or PAGE, "after": after}) or {} + rows += [r for r in raw.get("items") or [] if isinstance(r, dict)] + after = raw.get("next") or None + if not after or limit: + break + return rows, after + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Every resting order, or every one on a market, page by page.""" + params = {"status": "OPEN", "market": ids.native(VENUE, market_id) if market_id else None} + rows, _ = await self._paged("/v3/orders", params) + return await self._orders(rows) + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of orders in one state (the venue lists one at a time, + resting ones when none is given): open, pending, closed (filled), + canceled or rejected. `since` filters on the creation time here.""" + wanted = OrderStatus(status) if status else OrderStatus.OPEN + if wanted not in STATUS_FILTER: + raise BadRequest(f"{VENUE}: orders list by one of {', '.join(s.value for s in STATUS_FILTER)}") + params = {"status": STATUS_FILTER[wanted], "market": ids.native(VENUE, market_id) if market_id else None} + rows, following = await self._paged("/v3/orders", params, limit=min(limit or PAGE, 5000), cursor=cursor) + orders = await self._orders(rows) + if since: + orders = [o for o in orders if (o.created_at or 0) >= since] + return Page(orders, next_cursor=following) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """One page of the subaccount's fills, newest first, each with the fee + it paid (zero when it was not charged).""" + params = { + "market": ids.native(VENUE, market_id) if market_id else None, "order": order_id, + "startsAfter": since - 1 if since else None, + } + rows, following = await self._paged("/v3/portfolio/fills", params, limit=min(limit or PAGE, 5000), + cursor=cursor) + yes = await self._yes_of([str(r.get("marketId") or "") for r in rows]) + fills = [fill_of_row(r, yes_outcome=yes.get(str(r.get("marketId")), ""), account=self.account) for r in rows] + return Page(fills, next_cursor=following) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """The subaccount's balance in USD. The venue reports one figure, so + what resting orders hold is not split out (`locked` is `None`).""" + key_id = self.credentials.key_id + raw = await self._call("GET", f"/v3/account/subaccounts/{key_id}/balance") or {} + total = D(raw.get("balance")) + return Balance( + venue=VENUE, account=account or self.account, currency="USD", total=total, available=total, + locked=None, timestamp=int(time.time() * 1000), info=raw, + ) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """Open positions, one per market, both outcomes' holdings netted on + the YES leg.""" + params = {"market": ids.native(VENUE, market_id) if market_id else None, + "event": ids.native(VENUE, event_id) if event_id else None} + raw = await self._call("GET", "/v3/portfolio/positions", params=params) or [] + rows = [r for r in raw if isinstance(r, dict)] if isinstance(raw, list) else [] + by_market: dict[str, list[dict[str, Any]]] = {} + for row in rows: + by_market.setdefault(str(row.get("marketId") or ""), []).append(row) + yes = await self._yes_of(list(by_market)) + return [position_of(m, group, yes_outcome=yes.get(m, ""), account=self.account) + for m, group in by_market.items()] + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The taker fee on `quantity` contracts at the YES `price`, and the + maker's credit (negative), at the market's own coefficient. On a game + market these apply while the event is in play; a fill matched + pregame pays and earns nothing.""" + from ..novig import fee_estimate + + schedule = await asyncio.to_thread(self.catalog.fetch_fee_schedule, market_id) + taker, maker = fee_estimate(schedule, D(price), D(quantity)) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, ids.native(VENUE, market_id)), side=side, + price=D(price), quantity=D(quantity), taker_fee=taker, maker_fee=maker, currency="USD", + info={"schedule": schedule.model_dump()}, + ) + + # -- keys, subaccounts, funding ------------------------------------------- + + async def echo(self, body: Any = None, *, management: bool = False) -> Any: + """`POST /v3/echo`: the body back, which proves the host, key, clock + and signature are right.""" + return await self._call("POST", "/v3/echo", body=body if body is not None else {}, management=management) + + async def server_time(self) -> int: + """The venue's clock, in Unix milliseconds.""" + raw = await self._call("GET", "/v3/time", management=self.http is None) or {} + return int(raw.get("ts") or 0) + + async def limits(self) -> Any: + """The key's throttle schedule, as the venue reports it.""" + return await self._call("GET", "/v3/limits", management=self.http is None) + + async def subaccounts(self) -> list[dict[str, Any]]: + """Every live subaccount: its trading key id (its address), label and + balance. Management key.""" + return list(await self._call("GET", "/v3/account/subaccounts", kind="read", management=True) or []) + + async def account_balance(self) -> Decimal: + """The cash wallet that funds every subaccount. Management key.""" + raw = await self._call("GET", "/v3/account/balance", management=True) or {} + return D(raw.get("balance")) + + async def open_subaccount(self, label: str, public_key_pem: str, *, algorithm: sig.Algorithm = "Ed25519", + expires_at: str | None = None) -> dict[str, Any]: + """Open a subaccount with its one trading key, from the public half of + a keypair you generated (`novig_signing.generate_key`). The answer's + `keyId` is the new key's id and the subaccount's address. Management key.""" + body = {"label": label, "publicKey": public_key_pem, "algorithm": algorithm} + if expires_at: + body["expiresAt"] = expires_at + return await self._call("POST", "/v3/account/subaccounts", body=body, kind="write", management=True) or {} + + async def label_subaccount(self, key_id: str, label: str) -> dict[str, Any]: + return await self._call("PATCH", f"/v3/account/subaccounts/{key_id}", body={"label": label}, kind="write", + management=True) or {} + + async def issue_subaccount_key(self, key_id: str, name: str, public_key_pem: str, *, + scope: str = "trading", algorithm: sig.Algorithm = "Ed25519") -> dict[str, Any]: + """A replacement `trading` key (after the old one is revoked) or a + `trading::read` key for a subaccount. Management key.""" + body = {"name": name, "publicKey": public_key_pem, "algorithm": algorithm, "scope": scope} + return await self._call("POST", f"/v3/account/subaccounts/{key_id}/keys", body=body, kind="write", + management=True) or {} + + async def fund_subaccount(self, key_id: str, amount: Decimal, *, direction: str = "fund", + client_transfer_id: str | None = None) -> dict[str, Any]: + """Move money between the cash wallet and a subaccount: `fund` into + it, `defund` out. The venue accepts and applies it later; read + `transfer()` for `Applied` or `Rejected`. `client_transfer_id` is the + idempotency key (one is made when not given, and is in the answer): + resend the same one to retry. Management key.""" + if direction not in ("fund", "defund"): + raise BadRequest(f"{VENUE}: direction is fund or defund") + value = D(amount) + if value <= 0 or value != value.quantize(Decimal("0.00001")): + raise BadRequest(f"{VENUE}: a transfer is a positive amount with at most five decimal places") + body = {"direction": direction, "amount": str(value.quantize(Decimal("0.00001"))), + "clientTransferId": client_transfer_id or str(uuid.uuid4())} + return await self._call("POST", f"/v3/account/subaccounts/{key_id}/transfer", body=body, kind="write", + management=True) or {} + + async def transfer(self, key_id: str, transfer_id: str) -> dict[str, Any]: + return await self._call("GET", f"/v3/account/subaccounts/{key_id}/transfers/{transfer_id}", + management=True) or {} + + async def close(self) -> None: + for http in (self.http, self.management_http): + if http is not None: + await http.close() + if self._own_catalog: + self.catalog.close() + + +__all__ = ["NovigTrading", "order_of_row", "fill_of_row", "position_of", "build_order", "error_of"] diff --git a/synpath/trading/novig_signing.py b/synpath/trading/novig_signing.py new file mode 100644 index 0000000..dff2e19 --- /dev/null +++ b/synpath/trading/novig_signing.py @@ -0,0 +1,258 @@ +"""Novig request signing (NOVIG-V3), the price grid and contract units, with +no network in sight. + +**Every signed request carries three headers**: `Novig-Key-Id` (the key's +UUID), `Novig-Timestamp` (Unix milliseconds, within 30 s of the venue's +clock) and `Novig-Signature`, the standard padded base64 of a signature over +six lines joined by LF, with no trailing newline: + + NOVIG-V3 + {unix_millis} + {METHOD} + {path, exactly as sent: never decoded or normalized} + {canonical query, or an empty line} + {lowercase hex SHA-256 of the raw body bytes} + +The canonical query splits the raw query on `&` and each pair at its first +`=`, decodes only `%XX` (a bare `+` stays a `+`), re-encodes everything but +`A-Z a-z 0-9 - . _ ~` as uppercase `%XX`, and sorts by name, then value. + +**Two algorithms.** The venue reads the algorithm from the stored key, so +no header names it: an Ed25519 key signs the string itself (64 bytes); a +P-256 key signs it with ECDSA and SHA-256, DER-encoded (70 to 72 bytes). +`tests/samples/novig_signing_vectors.json` is the venue's published set of +thirty vectors and two test keypairs; the canonical string matches all of +them, and the Ed25519 signatures byte for byte. + +**Prices** are probabilities on a grid of 279: steps of 0.001 from 0.001 to +0.050 and from 0.950 to 0.999, steps of 0.005 between. **Quantities** are +whole numbers of 1-cent contracts, so this library's contract (one dollar) +is 100 of the venue's. +""" +from __future__ import annotations + +import base64 +import hashlib +import time +from decimal import ROUND_DOWN, Decimal +from typing import Any, Iterator, Literal + +import httpx + +from .errors import InvalidOrder + +SCHEME = "NOVIG-V3" +EMPTY_SHA256 = hashlib.sha256(b"").hexdigest() +UNRESERVED = frozenset(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + +QTY_PER_CONTRACT = 100 +"""The venue's contracts pay 1 cent; this library's pay a dollar.""" + +Algorithm = Literal["Ed25519", "P-256"] + + +# --------------------------------------------------------------------------- +# The canonical string +# --------------------------------------------------------------------------- + +def _decode(part: str) -> bytes: + """Only `%XX` decodes; a bare `+` stays a `+`, a stray `%` stays a `%`.""" + raw = part.encode("utf-8") + out = bytearray() + i = 0 + while i < len(raw): + if raw[i] == 0x25 and _is_hex(raw[i + 1:i + 3]): + out.append(int(raw[i + 1:i + 3], 16)) + i += 3 + continue + out.append(raw[i]) + i += 1 + return bytes(out) + + +def _is_hex(pair: bytes) -> bool: + return len(pair) == 2 and all(chr(b) in "0123456789abcdefABCDEF" for b in pair) + + +def _encode(raw: bytes) -> str: + return "".join(chr(b) if b in UNRESERVED else f"%{b:02X}" for b in raw) + + +def canonical_query(raw: str) -> str: + """Line 5 from the raw query string (without the `?`).""" + pairs = [] + for token in raw.split("&"): + if not token: + continue + name, _, value = token.partition("=") + pairs.append((_encode(_decode(name)), _encode(_decode(value)))) + pairs.sort(key=lambda pair: (pair[0].encode(), pair[1].encode())) + return "&".join(f"{name}={value}" for name, value in pairs) + + +def string_to_sign(timestamp_ms: int, method: str, path: str, query: str = "", body: bytes = b"") -> str: + """The six lines a request's signature covers.""" + digest = hashlib.sha256(body or b"").hexdigest() + return "\n".join((SCHEME, str(int(timestamp_ms)), method.upper(), path, canonical_query(query), digest)) + + +# --------------------------------------------------------------------------- +# Keys and the signer +# --------------------------------------------------------------------------- + +def load_private_key(pem: bytes) -> Any: + """An Ed25519 or P-256 private key from a PKCS#8 PEM.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + + key = serialization.load_pem_private_key(pem, password=None) + if isinstance(key, ed25519.Ed25519PrivateKey): + return key + if isinstance(key, ec.EllipticCurvePrivateKey) and isinstance(key.curve, ec.SECP256R1): + return key + raise ValueError(f"Novig keys are Ed25519 or P-256; this one is {type(key).__name__}") + + +def algorithm_of(key: Any) -> Algorithm: + from cryptography.hazmat.primitives.asymmetric import ed25519 + + return "Ed25519" if isinstance(key, ed25519.Ed25519PrivateKey) else "P-256" + + +class NovigSigner: + """One key: its id, and the private half that signs. The algorithm comes + from the key itself.""" + + def __init__(self, key_id: str, private_key_pem: bytes): + self.key_id = key_id + self._key = load_private_key(private_key_pem) + self.algorithm: Algorithm = algorithm_of(self._key) + + def __repr__(self) -> str: + return f"NovigSigner(key_id={self.key_id!r}, algorithm={self.algorithm!r})" + + def sign(self, message: str) -> str: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + + data = message.encode("utf-8") + if self.algorithm == "Ed25519": + signature = self._key.sign(data) + else: + signature = self._key.sign(data, ec.ECDSA(hashes.SHA256())) + return base64.b64encode(signature).decode() + + def headers( + self, method: str, path: str, query: str = "", body: bytes = b"", *, timestamp_ms: int | None = None, + ) -> dict[str, str]: + stamp = int(time.time() * 1000) if timestamp_ms is None else int(timestamp_ms) + return { + "Novig-Key-Id": self.key_id, + "Novig-Timestamp": str(stamp), + "Novig-Signature": self.sign(string_to_sign(stamp, method, path, query, body)), + } + + def public_key_pem(self) -> str: + return public_key_pem(self._key) + + +class NovigAuth(httpx.Auth): + """Signs every request as it is sent, for sync and async clients alike, + over the exact path, query and body bytes on the wire. A retried request + is signed again, with a fresh timestamp.""" + + requires_request_body = True + + def __init__(self, signer: NovigSigner): + self.signer = signer + + def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]: + path, _, query = request.url.raw_path.decode("ascii").partition("?") + request.headers.update(self.signer.headers(request.method, path, query, request.content or b"")) + yield request + + +def public_key_pem(private_key: Any) -> str: + from cryptography.hazmat.primitives import serialization + + return private_key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + +def generate_key(algorithm: Algorithm = "Ed25519") -> tuple[bytes, str]: + """A fresh keypair for a new subaccount or key: `(private PKCS#8 PEM, + public SPKI PEM)`. Only the public half is ever sent; keep the private + half in a secret store.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + + if algorithm == "Ed25519": + key: Any = ed25519.Ed25519PrivateKey.generate() + elif algorithm == "P-256": + key = ec.generate_private_key(ec.SECP256R1()) + else: + raise ValueError(f"algorithm must be Ed25519 or P-256, got {algorithm!r}") + private = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + return private, public_key_pem(key) + + +def fingerprint(public_pem: str | bytes) -> str: + """The SHA-256 of a public key's SPKI DER, in hex: what the venue reports + as a key's `fingerprint`.""" + from cryptography.hazmat.primitives import serialization + + data = public_pem.encode() if isinstance(public_pem, str) else public_pem + key = serialization.load_pem_public_key(data) + der = key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) + return hashlib.sha256(der).hexdigest() + + +# --------------------------------------------------------------------------- +# Prices and quantities +# --------------------------------------------------------------------------- + +MILLI = Decimal("0.001") + + +def on_grid(price: Decimal) -> bool: + """Whether a price is one the venue accepts.""" + milli = Decimal(str(price)) / MILLI + if milli != milli.to_integral_value(): + return False + value = int(milli) + return 1 <= value <= 50 or 950 <= value <= 999 or (51 <= value <= 949 and value % 5 == 0) + + +def snap_down(price: Decimal) -> Decimal: + """The highest grid price at or below `price`: what an order that buys + pays at most.""" + milli = int((Decimal(str(price)) / MILLI).to_integral_value(rounding=ROUND_DOWN)) + if 51 <= milli <= 949: + milli -= milli % 5 + if not 1 <= milli <= 999: + raise InvalidOrder(f"novig: price {price} is outside the venue's grid (0.001 to 0.999)") + return (Decimal(milli) * MILLI).quantize(MILLI) + + +def wire_price(price: Decimal) -> str: + """A grid price as the venue writes it: three places.""" + return str(Decimal(str(price)).quantize(MILLI)) + + +def qty_of(contracts: Decimal) -> int: + """This library's contracts as the venue's whole 1-cent contracts. A size + that is not a whole number of cents is refused, not rounded.""" + qty = Decimal(str(contracts)) * QTY_PER_CONTRACT + if qty != qty.to_integral_value() or qty < 1: + raise InvalidOrder( + f"novig: {contracts} contracts is not a whole number of the venue's 1-cent contracts " + f"(sizes step by 0.01, at least 0.01)" + ) + return int(qty) + + +def contracts_of(qty: Any) -> Decimal: + """The venue's 1-cent contracts as this library's dollar contracts.""" + return Decimal(int(qty or 0)) / QTY_PER_CONTRACT diff --git a/synpath/types.py b/synpath/types.py index 3233e1d..42b7757 100644 --- a/synpath/types.py +++ b/synpath/types.py @@ -559,11 +559,14 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo Kalshi schedule) the fee for the order is rounded up to the cent, as the venue charges it. Kalshi's `flat` type follows a separate table this library does not have, so it returns `None`. - * `quadratic_theta` (Polymarket US, Polymarket) — `theta * C * + * `quadratic_theta` (Polymarket US, Polymarket, Novig) — `theta * C * (P * (1 - P)) ** exponent`, the same shape with the coefficient published directly: `taker_rate` and `maker_rate` hold the venue's thetas, and a negative maker theta is a rebate, returned here as a negative fee. `exponent` is 1 unless the venue says otherwise. + Novig's maker theta is its maker credit, a share of the taker's + fee; on a game market both apply only while the event is in play + (`info["charged"]` is `WHEN_LIVE`). * `min_price` (predict.fun) — `rate * min(P, 1 - P) * C` for a taker, on the cheaper side of the market; a maker pays `maker_rate` (zero on every market read so far). diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index 6260aee..99b2cfb 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -56,6 +56,9 @@ def __getattr__(name: str): if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream + if name in ("NovigMarketStream", "NovigUserStream"): + from . import novig + return getattr(novig, name) if name in ("HyperliquidMarketStream", "HyperliquidUserStream"): from . import hyperliquid return getattr(hyperliquid, name) @@ -75,5 +78,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", ] diff --git a/synpath/ws/novig.py b/synpath/ws/novig.py new file mode 100644 index 0000000..4219b40 --- /dev/null +++ b/synpath/ws/novig.py @@ -0,0 +1,982 @@ +"""Novig's websocket: books, the tape, market status and the account's own +orders and positions; and, without a key, the public book polled. + +**One connection, signed.** `wss://api.novig.com/v3/ws` (Paper: +`wss://api.paper.novig.com/v3/ws`) takes a subaccount's `trading` or +`trading::read` key: the upgrade request is signed like any route (NOVIG-V3, +`GET /v3/ws`). Requests are JSON verbs with increasing nonces; `subscribe` +sets one channel per market (`book`, `bbo`, `trades` or `lifecycle`, the +first three including `lifecycle`) and answers with a snapshot. Deltas carry +a `seq` per market and channel; a skipped one is a gap, recovered with a +`snapshot` request that leaves the subscriptions as they are. + +**Books.** Each outcome's ladder is a list of resting orders. Read on YES +as the REST book is: orders on the YES outcome are bids, orders on the NO +outcome asks at one minus their price, sizes in dollar contracts (the +venue's `qty` over 100). The snapshot carries the top twenty price levels +per outcome; deltas (`add`, `update`, `remove`) apply per order. + +**One channel per market.** A market watched for its book and its trades +is subscribed on `book`, and its trades are read off the book's fills (a +resting order's quantity going down for a fill), without the tape's ids. + +**Without a key**, `NovigMarketStream` polls the public book route instead +(`GET /v3/public/catalog/markets/{id}/book`), and the tape and the market +for trades and status, turning the difference between one answer and the +next into the same events. The edge caches those answers for up to fifteen +seconds, so a poll sees changes that late. + +**The account** (`NovigUserStream`): the `orders` and `positions` channels +of the key's subaccount, a snapshot then events (`open`, `fill`, `cancel`, +`reject`), and a heartbeat every 15 s that names the last seq of each so a +dropped message is noticed. The `fill` event carries no fee; read it from +`NovigTrading.fetch_my_trades`. + +Message shapes follow Novig's documentation and OpenAPI document. +""" +from __future__ import annotations + +import asyncio +import json +import logging +from decimal import Decimal +from typing import Any + +import httpx + +from .. import ids +from ..base import Capability, RateLimiter +from ..errors import AuthenticationError +from ..trading.types import Account, OrderStatus, Side +from .base import ( + BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, + Stream, TradeEvent, now_ms, +) +from .betfair import changed # shared: the levels that differ between two book sides + +log = logging.getLogger("synpath.ws.novig") + +VENUE = "novig" +WS_URLS = {"production": "wss://api.novig.com/v3/ws", "paper": "wss://api.paper.novig.com/v3/ws"} +WS_PATH = "/v3/ws" +SCALE = Decimal(100) +ONE = Decimal("1") + +DEFAULT_INTERVAL = 5.0 +"""Seconds between polls without a key.""" + +MAX_POLLED = 200 +"""Markets one polled stream reads: a book request each per poll.""" + +SUBSCRIBE_CHUNK = 16 +"""Markets one subscribe names. A `book` subscription weighs 16 tokens a +market against a `stream` bucket of 512 that refills at 32 a second.""" + +STREAM_RATE = 32.0 +WEIGHT = {"lifecycle": 1, "trades": 4, "bbo": 8, "book": 16} + +MARKET_STATES = {"OPEN": "open", "CLOSED": "closed", "SETTLED": "settled"} +"""A market's status as a stream state.""" + +LIFECYCLE_STATES = {"OPEN": "open", "CLOSE": "closed", "GRADE": "settled"} +"""What each lifecycle transition makes of a market; the others (`START`, +`END`, `GOLIVE`, `UNLIVE`) leave its status and are passed on as `updated`.""" + + +def market_of(market_id: str) -> str: + return ids.native(VENUE, market_id) + + +def contracts(qty: Any) -> Decimal: + return Decimal(int(qty or 0)) / SCALE + + +def yes_level(price: Any, *, on_yes: bool) -> tuple[str, Decimal]: + """An outcome's resting price as a YES book side and price.""" + value = Decimal(str(price)) + return ("bid", value) if on_yes else ("ask", ONE - value) + + +def trade_event(row: dict[str, Any], *, yes_outcome: str, market: str, source: str = "tape") -> TradeEvent | None: + """One print, in the YES price with the taker's side: the tape names the + resting order's outcome and price, so a print on YES is a taker selling + YES, a print on NO a taker buying it.""" + try: + paid = Decimal(str(row["price"])) + quantity = contracts(row["qty"]) + except (KeyError, TypeError, ValueError, ArithmeticError): + return None + on_yes = str(row.get("outcomeId")) == yes_outcome + return TradeEvent( + venue=VENUE, market_id=ids.qualify(VENUE, market), id=row.get("tradeId"), + price=paid if on_yes else ONE - paid, quantity=quantity, taker_side=Side.SELL if on_yes else Side.BUY, + timestamp=row.get("ts"), info={**row, "source": source}, + ) + + +def lifecycle_kind(delta: Any) -> tuple[str, str | None]: + """(transition, status after) from a lifecycle delta: an object + `{kind, status}`, or the bare transition the documentation shows.""" + if isinstance(delta, dict): + return str(delta.get("kind") or ""), delta.get("status") + return str(delta), None + + +class _Signed(Stream): + """The connection both streams share: the signed upgrade and the nonces.""" + + venue = VENUE + + def __init__(self, *, credentials: Any = None, env: str | None = None, url: str | None = None, + signer: Any = None, **kwargs: Any): + env = env or getattr(credentials, "env", None) or "production" + if env not in WS_URLS: + raise ValueError(f"{VENUE}: env must be production or paper, got {env!r}") + self.env = env + if signer is None and getattr(credentials, "key_id", None) and getattr(credentials, "private_key_pem", None): + from ..trading.novig_signing import NovigSigner + + signer = NovigSigner(credentials.key_id, credentials.private_key_pem) + self.signer = signer + """The subaccount's trading key, which signs the upgrade; `None` without one.""" + super().__init__(url or WS_URLS[env], **kwargs) + self._nonce = 0 + + def headers(self) -> dict[str, str]: + if self.signer is None: + return {} + return self.signer.headers("GET", WS_PATH) + + def nonce(self) -> int: + self._nonce += 1 + return self._nonce + + def on_disconnect(self) -> None: + # Nonces only have to grow within a connection. + self._nonce = 0 + + def _later(self, coroutine: Any) -> asyncio.Task | None: + try: + return asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + coroutine.close() + return None + + def _error(self, message: dict[str, Any]) -> list[Event]: + code, text = message.get("code"), message.get("message") + self.status("error", f"venue refused request {message.get('nonce')}: {code} {text}".strip()) + return [] + + +class Throttled(Exception): + """The edge answered 429 to one polled read.""" + + +class PollSocket: + """One "connection" of polls: `recv` waits out the interval (not before the + first poll) and returns the next poll's answer as JSON text.""" + + def __init__(self, stream: "NovigMarketStream"): + self.stream = stream + self._first = True + self._closed = False + + async def recv(self) -> str: + if not self._first: + await asyncio.sleep(self.stream.interval) + self._first = False + if self._closed: + raise ConnectionError("closed") + return json.dumps(await self.stream.poll()) + + async def send(self, text: str) -> None: + """Subscriptions are local: what to poll is read from the stream.""" + + async def close(self) -> None: + self._closed = True + + +class NovigMarketStream(_Signed): + """Books, top of book, the tape and market status for Novig markets. + + With a trading key (`credentials`, a `NovigCredentials`) this is the + venue's websocket; without one it polls the public REST book every + `interval` seconds. + + ```python + async with NovigMarketStream(credentials=creds) as stream: + await stream.watch_order_book(["novig:01a1577c-d666-7e81-852e-68bed1446801"]) + async for event in stream: + ... + ``` + """ + + name = "market" + has: dict[str, Capability] = { + "watch_order_book": True, + "watch_ticker": True, + "watch_trades": True, + "watch_market_status": True, + } + + def __init__( + self, *, credentials: Any = None, catalog: Any = None, interval: float = DEFAULT_INTERVAL, + status_every: int = 6, client: httpx.AsyncClient | None = None, limiter: RateLimiter | None | str = "shared", + api_url: str | None = None, **kwargs: Any, + ): + super().__init__(credentials=credentials, **kwargs) + self.polling = self.signer is None + """No trading key: the public book is polled instead of the websocket.""" + if self.polling: + # Polled: a change shows up at the next poll, not as it happens. + self.has = {**type(self).has, "watch_order_book": "partial", "watch_ticker": "partial", + "watch_trades": "partial", "watch_market_status": "partial"} + self.idle_timeout = max(60.0, interval * 3 + 30) + if interval <= 0: + raise ValueError("interval must be positive") + self.interval = float(interval) + self.status_every = max(1, int(status_every)) + self.catalog = catalog + """A `Novig` read adapter: which outcome of each market is YES. Made when first needed.""" + self.credentials = credentials + self._client = client + self._own_client = client is None + self.api_url = api_url + from ..novig import LIMITER + + self.limiter = LIMITER if limiter == "shared" else limiter + self.books: dict[str, LocalBook] = {} + """Market id -> its YES book.""" + self.tickers: set[str] = set() + self.trades: set[str] = set() + self.statuses: set[str] = set() + self.sides: dict[str, tuple[str, str]] = {} + """Market id -> (YES outcome, NO outcome).""" + self.orders: dict[str, dict[str, tuple[str, Decimal, int]]] = {} + """Market id -> order id -> (outcome, price, qty): the resting orders a book is summed from.""" + self.seqs: dict[tuple[str, str], int] = {} + self.channels: dict[str, str] = {} + """Market id -> the channel subscribed on this connection.""" + self.polls = 0 + self._top: dict[str, tuple[Any, ...]] = {} + self._seen_trades: dict[str, set[str]] = {} + self._last_status: dict[str, str] = {} + self._bbo_state: dict[str, dict[str, Any]] = {} + """Market id -> outcome id -> its best bid, from the `bbo` channel.""" + + # -- outcomes ------------------------------------------------------------- + + def _catalog(self) -> Any: + if self.catalog is None: + from ..novig import Novig + + self.catalog = Novig(env=self.env, api_url=self.api_url, credentials=self.credentials) + return self.catalog + + async def _resolve(self, markets: list[str]) -> list[str]: + """Learn each market's YES and NO outcomes; markets the venue does not + know are reported and left out.""" + known = [] + for market in markets: + if market not in self.sides: + try: + self.sides[market] = await asyncio.to_thread(self._catalog().outcome_ids, market) + except Exception as exc: + self.status("error", f"cannot watch {market}: {type(exc).__name__}: {exc}", key=market) + continue + known.append(market) + return known + + # -- intents -------------------------------------------------------------- + + def channel_for(self, market: str) -> str | None: + """The one channel a market needs: `book` serves books, tickers and + (read off its fills) trades; otherwise the lightest that serves.""" + if market in self.books: + return "book" + if market in self.trades: + return "trades" + if market in self.tickers: + return "bbo" + if market in self.statuses: + return "lifecycle" + return None + + async def watch_order_book(self, market_ids: list[str]) -> None: + """Each market's book, kept per order and read on YES.""" + markets = await self._resolve([market_of(m) for m in market_ids]) + for market in markets: + self.books.setdefault(market, LocalBook()) + await self._subscribe(markets) + + async def watch_ticker(self, market_ids: list[str]) -> None: + """Top of book whenever it changes.""" + markets = await self._resolve([market_of(m) for m in market_ids]) + self.tickers.update(markets) + await self._subscribe(markets) + + async def watch_trades(self, market_ids: list[str] | None = None) -> None: + """Every execution on these markets.""" + markets = await self._resolve([market_of(m) for m in market_ids or []]) + self.trades.update(markets) + await self._subscribe(markets) + + async def watch_market_status(self, market_ids: list[str] | None = None) -> None: + """Each market's transitions: opened, closed, graded, and the event + starting, going in play (every resting order voided) or leaving it.""" + markets = await self._resolve([market_of(m) for m in market_ids or []]) + self.statuses.update(markets) + await self._subscribe(markets) + + async def unwatch(self, market_ids: list[str]) -> None: + dropped = [] + for market_id in market_ids: + market = market_of(market_id) + self.books.pop(market, None) + self.tickers.discard(market) + self.trades.discard(market) + self.statuses.discard(market) + self.orders.pop(market, None) + self._top.pop(market, None) + if market in self.channels: + self.channels.pop(market) + dropped.append(f"market:{market}") + if dropped and not self.polling: + await self.send({"nonce": self.nonce(), "unsubscribe": dropped}) + + def book(self, market_id: str, side: str = "yes") -> LocalBook | None: + book = self.books.get(market_of(market_id)) + if book is None: + return None + return book if side != "no" else book.mirrored() + + def watched(self) -> list[str]: + return sorted(set(self.books) | self.tickers | self.trades | self.statuses) + + # -- connection ----------------------------------------------------------- + + async def _open(self) -> Any: + if not self.polling: + return await super()._open() + if self._client is None: + self._client = httpx.AsyncClient(timeout=30.0, follow_redirects=True, + headers={"Accept": "application/json"}) + return PollSocket(self) + + async def close(self) -> None: + await super().close() + if self._client is not None and self._own_client: + await self._client.aclose() + self._client = None + + async def on_connect(self) -> None: + self.channels.clear() + await self._subscribe(self.watched()) + + def on_disconnect(self) -> None: + super().on_disconnect() + self.channels.clear() + self.seqs.clear() + for book in self.books.values(): + book.invalidate() + + async def _subscribe(self, markets: list[str]) -> None: + """Set each market's channel where it changed, a few markets a + request, paced under the stream throttle.""" + if self.polling: + if len(self.watched()) > MAX_POLLED: + self.status("error", f"more than {MAX_POLLED} markets on one polled stream; the newest are not polled") + return + changes = {} + for market in markets: + channel = self.channel_for(market) + if channel and self.channels.get(market) != channel: + changes[market] = channel + items = sorted(changes.items()) + for start in range(0, len(items), SUBSCRIBE_CHUNK): + chunk = dict(items[start:start + SUBSCRIBE_CHUNK]) + sent = await self.send({"nonce": self.nonce(), "subscribe": {"markets": chunk}}) + if not sent: + return + self.channels.update(chunk) + for market in chunk: + book = self.books.get(market) + if book is not None: + book.invalidate() + if start + SUBSCRIBE_CHUNK < len(items): + await asyncio.sleep(sum(WEIGHT[c] for c in chunk.values()) / STREAM_RATE) + + # -- polling -------------------------------------------------------------- + + async def _get(self, path: str, params: Any = None) -> Any: + if self.limiter is not None: + await self.limiter.acquire_async() + from ..novig import API_URLS + + base = (self.api_url or API_URLS[self.env]).rstrip("/") + assert self._client is not None + response = await self._client.get(f"{base}/v3/public/catalog{path}", params=params) + if response.status_code == 429: + raise Throttled(path) + if response.status_code >= 400: + raise ConnectionError(f"Novig answered {response.status_code} on {path}") + return response.json() + + async def poll(self) -> dict[str, Any]: + """One round of reads: the book of every market watched for its book or + ticker, the tape of those watched for trades, and every + `status_every` polls the markets watched for status.""" + markets = self.watched()[:MAX_POLLED] + self.polls += 1 + if not markets: + return {"op": "idle", "at": now_ms()} + message: dict[str, Any] = {"op": "poll", "at": now_ms(), "books": {}, "trades": {}, "markets": {}, + "throttled": []} + + async def read(kind: str, market: str, path: str, params: Any = None) -> None: + # A throttled read keeps what the last poll saw; the next one retries. + try: + body = await self._get(path, params) + except Throttled: + message["throttled"].append(path) + return + message[kind][market] = (body or {}).get("items") or [] if kind == "trades" else body + + for market in markets: + if market in self.books or market in self.tickers: + await read("books", market, f"/markets/{market}/book") + if market in self.trades: + await read("trades", market, f"/markets/{market}/trades", {"limit": 100}) + if (self.polls - 1) % self.status_every == 0: + for market in sorted(self.statuses): + await read("markets", market, f"/markets/{market}") + return message + + def _polled(self, message: dict[str, Any]) -> list[Event]: + events: list[Event] = [] + info = {"polled_at": message.get("at")} + if message.get("throttled"): + log.debug("synpath.ws novig: throttled at the edge, kept the last answer for %s", message["throttled"]) + for market, body in (message.get("books") or {}).items(): + sides = self.sides.get(market) + if not sides or not isinstance(body, dict): + continue + bids: dict[Decimal, Decimal] = {} + asks: dict[Decimal, Decimal] = {} + for outcome, rows in (body.get("orders") or {}).items(): + for row in rows or []: + side, price = yes_level(row["price"], on_yes=outcome == sides[0]) + levels = bids if side == "bid" else asks + levels[price] = levels.get(price, Decimal(0)) + contracts(row.get("qty")) + events += self._book_replaced(market, list(bids.items()), list(asks.items()), + {**info, "seq": body.get("seq")}) + for market, rows in (message.get("trades") or {}).items(): + sides = self.sides.get(market) + seen = self._seen_trades.get(market) + fresh = [r for r in rows if isinstance(r, dict) and r.get("tradeId")] + ids_now = {str(r["tradeId"]) for r in fresh} + if sides and seen is not None: + for row in sorted(fresh, key=lambda r: r.get("ts") or 0): + if str(row["tradeId"]) not in seen: + event = trade_event(row, yes_outcome=sides[0], market=market) + if event: + events.append(event) + self._seen_trades[market] = (seen or set()) | ids_now + for market, body in (message.get("markets") or {}).items(): + if isinstance(body, dict): + status = str(body.get("status") or "") + if self._last_status.get(market) not in (None, status): + events.append(MarketStatusEvent( + venue=VENUE, market_id=ids.qualify(VENUE, market), + state=MARKET_STATES.get(status, "updated"), # type: ignore[arg-type] + native=status, info=info, + )) + self._last_status[market] = status + return events + + def _book_replaced(self, market: str, bids: list[tuple[Decimal, Decimal]], asks: list[tuple[Decimal, Decimal]], + info: dict[str, Any], sequence: int | None = None) -> list[Event]: + """A whole book: a snapshot when the local book was not ready, else the + levels that changed.""" + events: list[Event] = [] + synpath_id = ids.qualify(VENUE, market) + book = self.books.get(market) + if book is not None: + snapshot = not book.ready + recovering = snapshot and book.timestamp is not None + old_bids, old_asks = book.levels() + book.replace(bids, asks) + book.timestamp = now_ms() + book.sequence = sequence + new_bids, new_asks = book.levels() + if snapshot: + events.append(BookEvent( + venue=VENUE, market_id=synpath_id, side="yes", kind="snapshot", bids=new_bids, asks=new_asks, + best_bid=book.best_bid, best_ask=book.best_ask, sequence=sequence, info=info, + )) + if recovering: + self.status("resynced", 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: + events.append(BookEvent( + venue=VENUE, market_id=synpath_id, side="yes", kind="delta", bids=bid_changes, + asks=ask_changes, best_bid=book.best_bid, best_ask=book.best_ask, sequence=sequence, + info=info, + )) + events += self._ticker(market, max(bids, default=None, key=lambda level: level[0]), + min(asks, default=None, key=lambda level: level[0]), info) + return events + + def _ticker(self, market: str, best_bid: Any, best_ask: Any, info: dict[str, Any]) -> list[Event]: + if market not in self.tickers: + return [] + top = (best_bid, best_ask) + if self._top.get(market) == top: + return [] + self._top[market] = top + return [QuoteEvent( + venue=VENUE, market_id=ids.qualify(VENUE, market), + bid=best_bid[0] if best_bid else None, bid_size=best_bid[1] if best_bid else None, + ask=best_ask[0] if best_ask else None, ask_size=best_ask[1] if best_ask else None, info=info, + )] + + # -- websocket messages --------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + if message.get("op") == "poll": + return self._polled(message) + if message.get("code") and "snapshot" not in message and "delta" not in message: + return self._error(message) + events: list[Event] = [] + if isinstance(message.get("subscribed"), dict): + markets = sorted((message["subscribed"].get("markets") or {}).keys()) + if markets: + self.status("subscribed", ", ".join(markets)) + for market, body in (message.get("snapshot") or {}).items(): + if isinstance(body, dict): + events += self._snapshot(str(market), body, message.get("ts")) + for market, body in (message.get("delta") or {}).items(): + if isinstance(body, dict): + events += self._delta(str(market), body, message.get("ts")) + return events + + def _in_order(self, market: str, channel: str, seq: Any) -> bool: + """Whether a delta follows the last seq of its market and channel. A + skipped one is a gap: the book is marked not ready and a snapshot + asked for.""" + if seq is None: + return True + key = (market, channel) + last = self.seqs.get(key) + self.seqs[key] = int(seq) + if last is None or int(seq) == last + 1: + return True + if int(seq) <= last: + self.seqs[key] = last + return False + self.status("gap", f"{channel} {market}: seq {last} then {seq}", key=ids.qualify(VENUE, market)) + book = self.books.get(market) + if book is not None and channel == "book": + book.invalidate() + current = self.channels.get(market) or channel + self._later(self.send({"nonce": self.nonce(), "snapshot": {"markets": {market: current}}})) + return channel != "book" + + def _snapshot(self, market: str, body: dict[str, Any], ts: Any) -> list[Event]: + events: list[Event] = [] + sides = self.sides.get(market) + for channel in ("book", "bbo", "trades", "lifecycle"): + part = body.get(channel) + if isinstance(part, dict) and part.get("seq") is not None: + self.seqs[(market, channel)] = int(part["seq"]) + book_part = body.get("book") + if isinstance(book_part, dict) and sides: + orders: dict[str, tuple[str, Decimal, int]] = {} + for outcome, rows in (book_part.get("orders") or {}).items(): + for row in rows or []: + order_id = str(row.get("orderId") or row.get("order") or "") + if order_id and row.get("price") is not None: + orders[order_id] = (str(outcome), Decimal(str(row["price"])), int(row.get("qty") or 0)) + self.orders[market] = orders + bids, asks = self._summed(market) + events += self._book_replaced(market, bids, asks, {"ts": ts}, sequence=book_part.get("seq")) + bbo = body.get("bbo") + if isinstance(bbo, dict) and sides: + events += self._bbo(market, bbo.get("bbo") or {}, ts, replace=True) + trades = body.get("trades") + if isinstance(trades, dict) and sides and market in self.trades: + for batch in trades.get("trades") or []: + for row in (batch or {}).get("deltas") or []: + event = trade_event(row, yes_outcome=sides[0], market=market) + if event: + events.append(event) + lifecycle = body.get("lifecycle") + if isinstance(lifecycle, dict) and market in self.statuses and lifecycle.get("status"): + status = str(lifecycle["status"]) + if self._last_status.get(market) != status: + self._last_status[market] = status + events.append(MarketStatusEvent( + venue=VENUE, market_id=ids.qualify(VENUE, market), + state=MARKET_STATES.get(status, "updated"), # type: ignore[arg-type] + native=status, timestamp=ts, info={"event_id": body.get("eventId"), "snapshot": True}, + )) + return events + + def _summed(self, market: str) -> tuple[list[tuple[Decimal, Decimal]], list[tuple[Decimal, Decimal]]]: + yes = self.sides[market][0] + bids: dict[Decimal, Decimal] = {} + asks: dict[Decimal, Decimal] = {} + for outcome, price, qty in self.orders.get(market, {}).values(): + side, level = yes_level(price, on_yes=outcome == yes) + levels = bids if side == "bid" else asks + levels[level] = levels.get(level, Decimal(0)) + contracts(qty) + return list(bids.items()), list(asks.items()) + + def _delta(self, market: str, body: dict[str, Any], ts: Any) -> list[Event]: + events: list[Event] = [] + sides = self.sides.get(market) + if not sides: + return events + part = body.get("book") + if isinstance(part, dict) and self._in_order(market, "book", part.get("seq")): + events += self._book_delta(market, part, ts) + part = body.get("bbo") + if isinstance(part, dict) and self._in_order(market, "bbo", part.get("seq")): + events += self._bbo(market, {str(d.get("outcomeId")): d.get("bbo") for d in part.get("deltas") or [] + if isinstance(d, dict)}, ts, replace=False) + part = body.get("trades") + if isinstance(part, dict) and self._in_order(market, "trades", part.get("seq")) and market in self.trades: + for row in part.get("deltas") or []: + event = trade_event(row, yes_outcome=sides[0], market=market) if isinstance(row, dict) else None + if event: + events.append(event) + part = body.get("lifecycle") + if isinstance(part, dict) and self._in_order(market, "lifecycle", part.get("seq")) and market in self.statuses: + for delta in part.get("deltas") or []: + kind, after = lifecycle_kind(delta) + if after: + self._last_status[market] = str(after) + events.append(MarketStatusEvent( + venue=VENUE, market_id=ids.qualify(VENUE, market), + state=LIFECYCLE_STATES.get(kind, "updated"), # type: ignore[arg-type] + native=kind, timestamp=ts, + info={"status": after, "event_id": body.get("eventId"), + "voids_resting_orders": kind in ("GOLIVE", "UNLIVE")}, + )) + return events + + def _book_delta(self, market: str, part: dict[str, Any], ts: Any) -> list[Event]: + """Per-order changes as level changes, and the fills among them as + trades when the market's trades are watched.""" + book = self.books.get(market) + orders = self.orders.setdefault(market, {}) + yes = self.sides[market][0] + touched: dict[tuple[str, Decimal], None] = {} + filled: dict[str, tuple[str, Decimal, int]] = {} + for delta in part.get("deltas") or []: + if not isinstance(delta, dict): + continue + kind = delta.get("kind") + order_id = str(delta.get("orderId") or delta.get("order") or "") + if kind == "add": + outcome = str(delta.get("outcomeId") or delta.get("outcome") or "") + price, qty = Decimal(str(delta["price"])), int(delta.get("qty") or 0) + if order_id in filled: + # A partial fill shown as a remove and an add of the rest. + gone = filled[order_id] + filled[order_id] = (gone[0], gone[1], gone[2] - qty) + orders[order_id] = (outcome, price, qty) + touched[yes_level(price, on_yes=outcome == yes)] = None + elif kind == "update" and order_id in orders: + outcome, price, qty = orders[order_id] + remaining = int(delta.get("remaining") or 0) + filled[order_id] = (outcome, price, filled.get(order_id, (outcome, price, 0))[2] + qty - remaining) + orders[order_id] = (outcome, price, remaining) + touched[yes_level(price, on_yes=outcome == yes)] = None + elif kind == "remove" and order_id in orders: + outcome, price, qty = orders.pop(order_id) + if delta.get("reason") == "fill": + filled[order_id] = (outcome, price, filled.get(order_id, (outcome, price, 0))[2] + qty) + touched[yes_level(price, on_yes=outcome == yes)] = None + events: list[Event] = [] + if book is not None and book.ready: + bids, asks = self._summed(market) + sizes = {("bid", p): s for p, s in bids} | {("ask", p): s for p, s in asks} + changes_bid, changes_ask = [], [] + for side, price in touched: + size = sizes.get((side, price), Decimal(0)) + book.set(side, price, size) # type: ignore[arg-type] + (changes_bid if side == "bid" else changes_ask).append(BookLevel(price, size)) + book.timestamp = now_ms() + book.sequence = part.get("seq") + if changes_bid or changes_ask: + events.append(BookEvent( + venue=VENUE, market_id=ids.qualify(VENUE, market), side="yes", kind="delta", + bids=tuple(changes_bid), asks=tuple(changes_ask), best_bid=book.best_bid, best_ask=book.best_ask, + sequence=part.get("seq"), timestamp=ts, + )) + best_bid = max(bids, default=None, key=lambda level: level[0]) + best_ask = min(asks, default=None, key=lambda level: level[0]) + events += self._ticker(market, best_bid, best_ask, {"ts": ts}) + if market in self.trades: + for order_id, (outcome, price, qty) in filled.items(): + if qty > 0: + event = trade_event({"outcomeId": outcome, "price": str(price), "qty": qty, "ts": ts, + "orderId": order_id}, yes_outcome=yes, market=market, source="book") + if event: + events.append(event) + return events + + def _bbo(self, market: str, levels: dict[str, Any], ts: Any, *, replace: bool) -> list[Event]: + """Each outcome's best bid as YES top of book.""" + yes, no = self.sides[market] + state = self._bbo_state.setdefault(market, {}) + if replace: + state.clear() + for outcome, level in levels.items(): + if level: + state[outcome] = level + else: + state.pop(outcome, None) + bid = state.get(yes) + ask = state.get(no) + best_bid = (Decimal(str(bid["price"])), contracts(bid.get("qty"))) if bid else None + best_ask = (ONE - Decimal(str(ask["price"])), contracts(ask.get("qty"))) if ask else None + return self._ticker(market, best_bid, best_ask, {"ts": ts}) + + +class NovigUserStream(_Signed): + """The subaccount's orders, fills and positions, from the websocket's + private channels. Needs the subaccount's trading (or trading-read) key. + + An order event names its order; which market and side it is on comes from + the order itself, kept from the snapshot and each `open`, and read from + `trading` (a `NovigTrading`) for an order first seen in a fill or a + cancel. Events are produced in the order they arrive. + """ + + name = "user" + private = True + data_heartbeat = True + has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True, "watch_positions": True} + + def __init__(self, *, credentials: Any = None, trading: Any = None, catalog: Any = None, + account_name: str = "default", **kwargs: Any): + super().__init__(credentials=credentials, **kwargs) + if self.signer is None: + raise AuthenticationError( + f"{VENUE}: the account stream needs the subaccount's trading key: NOVIG_KEY_ID and " + "NOVIG_PRIVATE_KEY_PATH" + ) + self.credentials = credentials + self.trading = trading + self.catalog = catalog if catalog is not None else getattr(trading, "catalog", None) + self.account = Account(venue=VENUE, name=account_name, subaccount=getattr(credentials, "key_id", None)) + self.channels: set[str] = set() + self.orders: dict[str, Any] = {} + """Order id -> the `Order` as last reported.""" + self.holdings: dict[str, dict[str, dict[str, Any]]] = {} + """Market id -> outcome id -> position row.""" + self.seqs: dict[str, int] = {} + self._queue_in: asyncio.Queue | None = None + self._worker: asyncio.Task | None = None + + async def watch_orders(self, market_ids: list[str] | None = None) -> None: + """Every order on the subaccount; the venue has no per-market channel.""" + await self._add({"orders"}) + + async def watch_my_trades(self, market_ids: list[str] | None = None) -> None: + """Every fill on the subaccount (on the orders channel).""" + await self._add({"orders"}) + + async def watch_positions(self, market_ids: list[str] | None = None) -> None: + await self._add({"positions"}) + + async def _add(self, channels: set[str]) -> None: + new = channels - self.channels + self.channels |= channels + if new: + await self.send({"nonce": self.nonce(), "subscribe": {"private": sorted(self.channels)}}) + + async def on_connect(self) -> None: + self.seqs.clear() + if self.channels: + await self.send({"nonce": self.nonce(), "subscribe": {"private": sorted(self.channels)}}) + + async def close(self) -> None: + if self._worker is not None: + self._worker.cancel() + self._worker = None + await super().close() + + # -- messages ------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + if message.get("code") and "snapshot" not in message and "delta" not in message: + return self._error(message) + if isinstance(message.get("heartbeat"), dict): + self._heartbeat(message["heartbeat"]) + return [] + if "orders" not in message and "positions" not in message: + return [] + # Reading a market's sides or an unknown order may need the network, + # so messages are worked through one at a time, in order. + if self._queue_in is None: + self._queue_in = asyncio.Queue() + self._queue_in.put_nowait(message) + if self._worker is None or self._worker.done(): + self._worker = self._later(self._work()) + return [] + + async def _work(self) -> None: + assert self._queue_in is not None + while not self._queue_in.empty(): + message = self._queue_in.get_nowait() + try: + for event in await self.process(message): + self.emit(event) + except Exception as exc: + log.debug("synpath.ws novig: unreadable message %r", message, exc_info=True) + self.status("error", f"unreadable message: {type(exc).__name__}: {exc}") + + def _heartbeat(self, beat: dict[str, Any]) -> None: + for channel in ("orders", "positions"): + seq = beat.get(channel) + if seq is not None and channel in self.seqs and int(seq) > self.seqs[channel]: + self._gap(channel, f"heartbeat says {seq}, last seen {self.seqs[channel]}") + + def _gap(self, channel: str, detail: str) -> None: + self.status("gap", f"{channel}: {detail}", key=channel, reconcile=True) + self._later(self.send({"nonce": self.nonce(), "snapshot": {"private": [channel]}})) + + def _follows(self, channel: str, seq: Any) -> bool: + if seq is None: + return True + last = self.seqs.get(channel) + self.seqs[channel] = int(seq) + if last is not None and int(seq) <= last: + self.seqs[channel] = last + return False + if last is not None and int(seq) != last + 1: + self._gap(channel, f"seq {last} then {seq}") + return True + + async def _yes(self, market: str) -> str: + if self.catalog is None: + from ..novig import Novig + + self.catalog = Novig(env=self.env, credentials=self.credentials) + return (await asyncio.to_thread(self.catalog.outcome_ids, market))[0] + + async def process(self, message: dict[str, Any]) -> list[Event]: + """One private message as events: a snapshot of the resting orders and + positions, or a batch of order events and position changes.""" + from ..trading.novig import order_of_row, position_of + + events: list[Event] = [] + orders = message.get("orders") + if isinstance(orders, dict): + if "open" in orders: + self.seqs["orders"] = int(orders.get("seq") or 0) + for row in orders.get("open") or []: + order = order_of_row(row, yes_outcome=await self._yes(str(row["marketId"])), account=self.account) + self.orders[order.id] = order + events.append(OrderEvent(venue=VENUE, order=order, native="snapshot")) + elif self._follows("orders", orders.get("seq")): + # Fills matched on entry come before the order's `open` in its + # batch: learn the order from the `open` first. + for delta in orders.get("deltas") or []: + opening = isinstance(delta, dict) and delta.get("kind") == "open" + if opening and delta.get("orderId") not in self.orders: + self.orders[str(delta["orderId"])] = order_of_row( + {**delta, "status": "PENDING", "remaining": delta.get("qty")}, + yes_outcome=await self._yes(str(delta["marketId"])), account=self.account) + for delta in orders.get("deltas") or []: + if isinstance(delta, dict): + events += await self._order_event(delta, order_of_row) + positions = message.get("positions") + if isinstance(positions, dict): + rows = None + if "positions" in positions: + self.seqs["positions"] = int(positions.get("seq") or 0) + self.holdings.clear() + rows = positions.get("positions") or [] + elif self._follows("positions", positions.get("seq")): + rows = positions.get("deltas") or [] + changed_markets: list[str] = [] + for row in rows or []: + market, outcome = str(row.get("marketId") or ""), str(row.get("outcomeId") or "") + held = self.holdings.setdefault(market, {}) + if int(row.get("qty") or 0) == 0: + held.pop(outcome, None) + else: + held[outcome] = row + if market not in changed_markets: + changed_markets.append(market) + for market in changed_markets: + position = position_of(market, list(self.holdings.get(market, {}).values()), + yes_outcome=await self._yes(market), account=self.account) + events.append(PositionEvent(venue=VENUE, position=position)) + if not self.holdings.get(market): + self.holdings.pop(market, None) + return events + + async def _known(self, order_id: str) -> Any: + order = self.orders.get(order_id) + if order is None and self.trading is not None: + try: + order = await self.trading.fetch_order(order_id) + except Exception as exc: + self.status("error", f"order {order_id} is not known to this stream: {type(exc).__name__}: {exc}", + reconcile=True) + return None + self.orders[order_id] = order + return order + + async def _order_event(self, delta: dict[str, Any], order_of_row: Any) -> list[Event]: + from ..trading.novig import fill_of_row + + kind = str(delta.get("kind") or "") + order_id = str(delta.get("orderId") or "") + if kind == "open": + row = {**delta, "status": "OPEN"} + order = order_of_row(row, yes_outcome=await self._yes(str(delta["marketId"])), account=self.account) + previous = self.orders.get(order_id) + if previous is not None and previous.filled: + # Fills matched on entry arrive before the `open`. + order = order.model_copy(update={ + "quantity": previous.filled + order.remaining, "filled": previous.filled, + }) + self.orders[order_id] = order + return [OrderEvent(venue=VENUE, order=order, native="open")] + order = await self._known(order_id) + if order is None: + if self.trading is None: + self.status("error", f"{kind} on order {order_id}, which this stream has not seen; pass trading= " + "to read it", reconcile=True) + return [] + market = ids.native(VENUE, order.market_id) + if kind == "fill": + fill = fill_of_row(delta, yes_outcome=await self._yes(market), market_id=market, account=self.account) + remaining = contracts(delta.get("remaining")) + filled = order.filled + fill.quantity + quantity = max(order.quantity, filled + remaining) + status = OrderStatus.CLOSED if remaining == 0 else OrderStatus.OPEN + order = order.model_copy(update={ + "filled": filled, "remaining": remaining, "quantity": quantity, "status": status, + "last_fill_price": fill.price, "last_fill_quantity": fill.quantity, + }) + self.orders[order_id] = order + return [FillEvent(venue=VENUE, fill=fill), OrderEvent(venue=VENUE, order=order, native="fill")] + if kind in ("cancel", "reject"): + status = OrderStatus.CANCELED if kind == "cancel" else OrderStatus.REJECTED + order = order.model_copy(update={"status": status, "remaining": Decimal("0"), + "info": {**order.info, "reason": delta.get("reason")}}) + self.orders.pop(order_id, None) + return [OrderEvent(venue=VENUE, order=order, native=f"{kind}:{delta.get('reason') or ''}")] + return [] diff --git a/tests/samples/novig_book.json b/tests/samples/novig_book.json new file mode 100644 index 0000000..f9a3f31 --- /dev/null +++ b/tests/samples/novig_book.json @@ -0,0 +1,70 @@ +{ + "marketId": "01a1577c-d666-7e81-852e-68bed1446801", + "seq": 63884, + "orders": { + "01a1577c-d666-7e81-852e-68ce96100d9c": [ + { + "orderId": "01a1672f-2086-7653-87ea-bfb1bf8f2d9c", + "price": "0.625", + "qty": 4456084 + }, + { + "orderId": "01a16772-d88b-77a0-b470-881ee523d078", + "price": "0.625", + "qty": 300000 + }, + { + "orderId": "01a16801-4034-7d43-a63c-dec6caae9b6c", + "price": "0.625", + "qty": 128000 + }, + { + "orderId": "01a1655a-1847-7971-86e7-f25258994cc4", + "price": "0.62", + "qty": 322580 + }, + { + "orderId": "01a1660e-b191-70e0-be04-e1bd1acdb3c2", + "price": "0.62", + "qty": 19300 + }, + { + "orderId": "01a166da-9c38-73a3-aae3-79223db2f871", + "price": "0.62", + "qty": 1612 + } + ], + "01a1577c-d666-7e81-852e-68d273380247": [ + { + "orderId": "01a16955-dc42-7762-9019-3826dcd1295c", + "price": "0.37", + "qty": 91970 + }, + { + "orderId": "01a1695a-2734-7791-8c33-d429e520f97d", + "price": "0.37", + "qty": 100000 + }, + { + "orderId": "01a1697f-7140-7bc0-801a-7af58c2e8328", + "price": "0.37", + "qty": 1351 + }, + { + "orderId": "01a165a9-4878-72e2-b3e5-26590c086310", + "price": "0.365", + "qty": 40778 + }, + { + "orderId": "01a165fd-9f63-7d20-86a5-941831c9c9b7", + "price": "0.365", + "qty": 200000 + }, + { + "orderId": "01a16646-3e56-7aa2-9ed4-aa6b15c01cfd", + "price": "0.365", + "qty": 219178 + } + ] + } +} \ No newline at end of file diff --git a/tests/samples/novig_event.json b/tests/samples/novig_event.json new file mode 100644 index 0000000..7b1f3aa --- /dev/null +++ b/tests/samples/novig_event.json @@ -0,0 +1,8 @@ +{ + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "description": "Toronto Maple Leafs @ Edmonton Oilers", + "sport": "ICE_HOCKEY", + "league": "NHL", + "status": "OPEN_PREGAME", + "startsTs": 1792882800000 +} \ No newline at end of file diff --git a/tests/samples/novig_event_markets.json b/tests/samples/novig_event_markets.json new file mode 100644 index 0000000..2897bf3 --- /dev/null +++ b/tests/samples/novig_event_markets.json @@ -0,0 +1,189 @@ +{ + "items": [ + { + "marketId": "01a1696f-6a69-7241-8d7a-b127e34701e6", + "description": "EDM", + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "marketType": "MONEY", + "strike": "0", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792882800000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1696f-6a69-7241-8d7a-b1323e3fb4cd", + "name": "EDM", + "status": "TBD", + "bid": { + "price": "0.605", + "qty": 100000 + } + }, + { + "outcomeId": "01a1696f-6a69-7241-8d7a-b14045396ef7", + "name": "TOR", + "status": "TBD", + "bid": { + "price": "0.35", + "qty": 10000 + } + } + ], + "volume": 0, + "openInterest": 0 + }, + { + "marketId": "01a1696f-6a6a-71c3-9c03-173319eb820f", + "description": "EDM +1.5", + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "marketType": "SPREAD", + "strike": "1.5", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792882800000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-1749ce00ded9", + "name": "EDM +1.5", + "status": "TBD", + "bid": { + "price": "0.76", + "qty": 15000 + } + }, + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-17505ea23035", + "name": "TOR -1.5", + "status": "TBD", + "bid": { + "price": "0.17", + "qty": 15000 + } + } + ], + "volume": 0, + "openInterest": 0 + }, + { + "marketId": "01a1696f-6a6a-71c3-9c03-1701718f9728", + "description": "EDM -2.5", + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "marketType": "SPREAD", + "strike": "-2.5", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792882800000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-17131cdf5a1d", + "name": "EDM -2.5", + "status": "TBD", + "bid": { + "price": "0.27", + "qty": 15000 + } + }, + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-172b69e0fe1b", + "name": "TOR +2.5", + "status": "TBD", + "bid": { + "price": "0.66", + "qty": 50000 + } + } + ], + "volume": 0, + "openInterest": 0 + }, + { + "marketId": "01a1696f-6a6a-71c3-9c03-16728c7d4c6b", + "description": "TOR @ EDM t9.5", + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "marketType": "TOTAL", + "strike": "9.5", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792882800000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-16850c948f4d", + "name": "Over 9.5", + "status": "TBD", + "bid": { + "price": "0.09", + "qty": 35000 + } + }, + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-1695af89ca60", + "name": "Under 9.5", + "status": "TBD", + "bid": { + "price": "0.76", + "qty": 35000 + } + } + ], + "volume": 0, + "openInterest": 0 + }, + { + "marketId": "01a1696f-6a6a-71c3-9c03-164f7dd1765a", + "description": "TOR @ EDM t8.5", + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "marketType": "TOTAL", + "strike": "8.5", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792882800000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-1650058efc82", + "name": "Over 8.5", + "status": "TBD", + "bid": { + "price": "0.22", + "qty": 15000 + } + }, + { + "outcomeId": "01a1696f-6a6a-71c3-9c03-1668996f2e62", + "name": "Under 8.5", + "status": "TBD", + "bid": { + "price": "0.71", + "qty": 15000 + } + } + ], + "volume": 0, + "openInterest": 0 + } + ] +} \ No newline at end of file diff --git a/tests/samples/novig_events.json b/tests/samples/novig_events.json new file mode 100644 index 0000000..f284fb4 --- /dev/null +++ b/tests/samples/novig_events.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "description": "Toronto Maple Leafs @ Edmonton Oilers", + "sport": "ICE_HOCKEY", + "league": "NHL", + "status": "OPEN_PREGAME", + "startsTs": 1792882800000 + }, + { + "eventId": "01a1577c-d5f2-7a31-85bc-3a2230bd25f9", + "description": "Tampa Bay Rays @ Milwaukee Brewers", + "sport": "BASEBALL", + "league": "MLB", + "status": "OPEN_PREGAME", + "startsTs": 1792800000000 + }, + { + "eventId": "019fcbf2-0856-7553-bfb1-c7f7db11f420", + "description": "Defensive Player Of The Year Winner", + "sport": "FOOTBALL", + "league": "NFL", + "status": "OPEN_PREGAME", + "startsTs": 1802647860000 + }, + { + "eventId": "01a1659d-8667-7c52-a486-0f35d45d48a9", + "description": "Kevin Roy @ Ben Kohles Round 2", + "sport": "GOLF", + "league": "PGA", + "status": "OPEN_INGAME", + "startsTs": 1792769880000 + }, + { + "eventId": "01a15f1b-8192-7ab3-9f0b-a8016fb33365", + "description": "Henrique Rocha @ Elmer Moller Quarterfinals", + "sport": "TENNIS", + "league": "ATP", + "status": "FINAL", + "startsTs": 1792763700000 + }, + { + "eventId": "01a15444-e60c-78d2-9b82-92733edac5d8", + "description": "Iga Swiatek @ Marie Bouzkova Round of 16", + "sport": "TENNIS", + "league": "WTA", + "status": "CANCELED", + "startsTs": 1792558800000 + } + ], + "next": "01a15444-e60c-78d2-9b82-92733edac5d8" +} \ No newline at end of file diff --git a/tests/samples/novig_market.json b/tests/samples/novig_market.json new file mode 100644 index 0000000..84a7244 --- /dev/null +++ b/tests/samples/novig_market.json @@ -0,0 +1,39 @@ +{ + "marketId": "01a1577c-d666-7e81-852e-68bed1446801", + "description": "MIL", + "eventId": "01a1577c-d5f2-7a31-85bc-3a2230bd25f9", + "marketType": "MONEY", + "strike": "0", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792800000000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1577c-d666-7e81-852e-68ce96100d9c", + "name": "MIL", + "status": "TBD", + "last": "0.63", + "bid": { + "price": "0.625", + "qty": 7823560 + } + }, + { + "outcomeId": "01a1577c-d666-7e81-852e-68d273380247", + "name": "TB", + "status": "TBD", + "last": "0.37", + "bid": { + "price": "0.37", + "qty": 193321 + } + } + ], + "volume": 52951388, + "openInterest": 37465750 +} \ No newline at end of file diff --git a/tests/samples/novig_markets.json b/tests/samples/novig_markets.json new file mode 100644 index 0000000..960e87c --- /dev/null +++ b/tests/samples/novig_markets.json @@ -0,0 +1,143 @@ +{ + "items": [ + { + "marketId": "01a1577c-d666-7e81-852e-68bed1446801", + "description": "MIL", + "eventId": "01a1577c-d5f2-7a31-85bc-3a2230bd25f9", + "marketType": "MONEY", + "strike": "0", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792800000000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1577c-d666-7e81-852e-68ce96100d9c", + "name": "MIL", + "status": "TBD", + "last": "0.63", + "bid": { + "price": "0.625", + "qty": 7823560 + } + }, + { + "outcomeId": "01a1577c-d666-7e81-852e-68d273380247", + "name": "TB", + "status": "TBD", + "last": "0.37", + "bid": { + "price": "0.37", + "qty": 193321 + } + } + ], + "volume": 52951388, + "openInterest": 37465750 + }, + { + "marketId": "01a16628-b058-7ff1-afc5-0e1a35958ec3", + "description": "Kyler Gordon DPOTY", + "eventId": "019fcbf2-0856-7553-bfb1-c7f7db11f420", + "marketType": "DPOTY", + "strike": "0", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1802647860000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.5", + "charged": "ALWAYS" + }, + "outcomes": [ + { + "outcomeId": "01a16628-b058-7ff1-afc5-0e289361c436", + "name": "Yes", + "status": "TBD" + }, + { + "outcomeId": "01a16628-b058-7ff1-afc5-0e39cdc5f37d", + "name": "No", + "status": "TBD" + } + ], + "volume": 0, + "openInterest": 0 + }, + { + "marketId": "01a1679c-a16f-7971-bdee-2552c9ef5215", + "description": "Anthony Cirelli FIRST_GOAL_SCORER", + "eventId": "01a16464-d306-7a91-b4e8-7a127088c4dc", + "marketType": "FIRST_GOAL_SCORER", + "strike": "0", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792803600000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1679c-a16f-7971-bdee-256d29c069f8", + "name": "Yes", + "status": "TBD" + }, + { + "outcomeId": "01a1679c-a16f-7971-bdee-257e22cfabd4", + "name": "No", + "status": "TBD", + "bid": { + "price": "0.865", + "qty": 2023 + } + } + ], + "volume": 0, + "openInterest": 0 + }, + { + "marketId": "01a1696f-6a69-7241-8d7a-b127e34701e6", + "description": "EDM", + "eventId": "01a1696f-6a01-7980-8ca5-81bfd7a21ea7", + "marketType": "MONEY", + "strike": "0", + "status": "OPEN", + "voids": "FMV", + "startsTs": 1792882800000, + "fee": { + "coefficient": "0.06", + "makerCredit": "0.25", + "charged": "WHEN_LIVE" + }, + "outcomes": [ + { + "outcomeId": "01a1696f-6a69-7241-8d7a-b1323e3fb4cd", + "name": "EDM", + "status": "TBD", + "bid": { + "price": "0.605", + "qty": 100000 + } + }, + { + "outcomeId": "01a1696f-6a69-7241-8d7a-b14045396ef7", + "name": "TOR", + "status": "TBD", + "bid": { + "price": "0.35", + "qty": 10000 + } + } + ], + "volume": 0, + "openInterest": 0 + } + ], + "next": "01a1696f-6a69-7241-8d7a-b127e34701e6" +} \ No newline at end of file diff --git a/tests/samples/novig_signing_vectors.json b/tests/samples/novig_signing_vectors.json new file mode 100644 index 0000000..ca2f7c8 --- /dev/null +++ b/tests/samples/novig_signing_vectors.json @@ -0,0 +1,610 @@ +{ + "$schema": "https://novig.co/schemas/signing-vectors/v1", + "spec_version": "NOVIG-V3", + "description": "NOVIG-V3 string-to-sign specification and committed test vectors. Both the Rust and Nest signing stacks test against this file. It is also the source that the Mintlify docs publish verbatim.", + "string_to_sign_rules": { + "format": "Six lines joined by LF (U+000A, byte 0x0A), no trailing newline:\n line 1: NOVIG-V3\n line 2: {unix_millis} — unpadded decimal, same value as Novig-Timestamp\n line 3: {METHOD} — uppercase ASCII\n line 4: {path} — with leading /, sent verbatim, never normalised\n line 5: {canonical_query} — see below; empty string when no query\n line 6: {lowercase_hex(sha256(raw_body))} — lowercase hex", + "canonical_query": "1. Split the raw query string on '&'.\n2. For each pair: split at the FIRST '=' only. The substring before the first '='\n is the name; the substring after is the value. If no '=' is present, the name\n is the whole token and the value is the empty string. This means a value that\n itself contains '=' (e.g. a base64 string 'abc=') is preserved correctly.\n3. Call URI-decode on each part, interpreting percent-encoded sequences\n as UTF-8. A bare '+' is NOT decoded as a space — RFC 3986 does not give '+'\n special meaning in query strings. Only '%XX' sequences are decoded.\n4. Re-encode each decoded part: keep RFC 3986 unreserved characters\n (ALPHA DIGIT - . _ ~) unchanged; encode everything else as %XX with\n UPPERCASE hex digits.\n5. Sort the encoded pairs bytewise: first by encoded name, then by encoded\n value as a tiebreaker (relevant when the same name appears more than once).\n6. Join with '&'.\n7. When no query string is present, the canonical query is the empty string,\n producing an empty line 5. The line is never omitted.", + "divergence_points": { + "absent_query": "When no query string is present, line 5 is an empty string. The line must appear; it is never omitted from the string to sign.", + "empty_body": "An absent or empty body hashes to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. The literal string '{}' produces a different hash.", + "plus_vs_percent20": "Spaces must be encoded as %20, not as '+'. A '+' in the input is treated as a literal '+' and re-encoded as %2B.", + "hex_case": "Percent-encoding in the canonical query always uses uppercase hex (e.g., %2F not %2f). The body hash always uses lowercase hex.", + "sort_by_name_then_value": "Query parameters are sorted bytewise by encoded name, then by encoded value. When the same name appears more than once, all occurrences are preserved and sorted by their value. The sort is never by name only.", + "duplicate_query_params": "When a parameter name appears more than once, all occurrences are preserved in the canonical query. None are dropped or merged.", + "trailing_slash": "The path is included exactly as sent. A trailing slash is never added or removed. /api/orders and /api/orders/ produce different string to signs.", + "path_not_decoded": "The path is never percent-decoded before canonicalization. /api/v1/items/foo%2Fbar is not the same as /api/v1/items/foo/bar.", + "non_ascii_value": "Non-ASCII query values are percent-encoded as their UTF-8 byte sequences with uppercase hex. For example, ü (U+00FC) encodes as %C3%BC.", + "equals_in_query_value": "A '=' character inside a query value is encoded as '%3D' in the canonical form. Split each pair at the FIRST '=' only; any subsequent '=' belongs to the value. Example: 'token=abc=' → name 'token', value 'abc=', canonical 'token=abc%3D'.", + "bare_query_param": "A query token with no '=' (e.g. '?flag') has name equal to the whole token and value equal to the empty string. Canonical form: 'flag='." + } + }, + "headers": { + "Novig-Key-Id": "The key's UUID. Not included in the string to sign.", + "Novig-Timestamp": "Unix time in milliseconds, as an unpadded decimal integer. Accepted clock skew is ±30 seconds.", + "Novig-Signature": "Standard padded base64 of the raw signature bytes. For Ed25519: the 64-byte output of the Ed25519 signing operation. For ECDSA P-256: sign the string to sign with ECDSA using the P-256 curve and the SHA-256 hash function (i.e. ES256 / prime256v1 + SHA-256). The output is the DER-encoded ASN.1 SEQUENCE of two INTEGERs (r, s), as produced by OpenSSL and Node.js crypto.sign('SHA256', data, ecKey). The DER signature length varies (typically 70–72 bytes); this is expected." + }, + "algorithm_note": "The client never sends the algorithm in the request. The verifier reads it from the stored key row. This prevents algorithm-confusion attacks (analogous to the JWT alg:none class).", + "keypairs": { + "ed25519-test-1": { + "label": "TEST KEY — NOT FOR PRODUCTION USE", + "algorithm": "ed25519", + "private_key_pkcs8_pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIJvNzamVA44euwrCHa4ZdHbACbiOx+HIYd07z4WUnIL3\n-----END PRIVATE KEY-----\n", + "public_key_spki_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAXs8T0ekGF/etWU01dggL39Ojb0+1YtuTtbNx6gr8K+Q=\n-----END PUBLIC KEY-----\n", + "public_key_fingerprint": "sha256:6d4058ea55f365a60868b7a29f302deee40cd738f1ee98f14f43381c13f1a232" + }, + "ecdsa-p256-test-1": { + "label": "TEST KEY — NOT FOR PRODUCTION USE", + "algorithm": "ecdsa-p256", + "private_key_pkcs8_pem": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4Da5JjoTw0/Qr4qg\nJFt7dDOE7AOxh+lAK3q+kdzctUChRANCAAQTaERl4TLnQHkdaLKN9WP1mSIOk6UB\nX89QHEHCX1YpJfkx5HnSI2SitUTYs21DlKz2VCS+kTNb7miyuygnlfGz\n-----END PRIVATE KEY-----\n", + "public_key_spki_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEE2hEZeEy50B5HWiyjfVj9ZkiDpOl\nAV/PUBxBwl9WKSX5MeR50iNkorVE2LNtQ5Ss9lQkvpEzW+5osrsoJ5Xxsw==\n-----END PUBLIC KEY-----\n", + "public_key_fingerprint": "sha256:c17518535299f8a3cc3a2cece4aaf612d3826bb001d4d7be60d33199ce3f46ee" + } + }, + "vectors": [ + { + "id": "ed25519_get_absent_query_empty_body", + "description": "GET with no query string and empty body. The string-to-sign line 5 is empty (never omitted). The body hash is the SHA-256 of zero bytes.", + "covers_divergence_point": [ + "absent_query", + "empty_body" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders/all", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders/all\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "Joy/3dQBha3GmPeSqlXLvx871R/vRcm6eEJsRja82vzdEyfXulwC15+T+fDwVzvRHnwHJRurOatFQfVpw2M2DA==" + }, + { + "id": "ed25519_post_empty_body", + "description": "POST with an empty body. The body hash must equal the SHA-256 of zero bytes: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.", + "covers_divergence_point": [ + "empty_body" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "POST", + "path": "/api/orders", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nPOST\n/api/orders\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "HMhMgjvlkg7ftMQeXkct6okH9AaSGdv68hR2NtfW3GTOdFzYGLn7SAlo4Z4Y+JB7yNhR/LTA8MMMk6lkY9wHAA==" + }, + { + "id": "ed25519_post_json_body", + "description": "POST with a JSON body. The body hash is the SHA-256 of the raw UTF-8 bytes, not of the parsed object.", + "covers_divergence_point": [], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "POST", + "path": "/api/orders", + "query": "", + "body": "{\"side\":\"BUY\",\"price\":\"0.55\",\"size\":\"100\",\"market_id\":\"abc123\"}" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nPOST\n/api/orders\n\n3d8f6e268d1ac3ab4943df1c1e1a985e0f1d411327e364d1ab147ea6e9c26936", + "body_hash": "3d8f6e268d1ac3ab4943df1c1e1a985e0f1d411327e364d1ab147ea6e9c26936", + "signature": "/SIxOTIOTqWTbvU8eREjb6jISel1qGXm8r2tWqtj5VL48vN3wk5jWBmH+xLCCAhsdcnOyKvxFL5uL1n0VWUOAQ==" + }, + { + "id": "ed25519_post_empty_json_object", + "description": "POST with a literal {} body. This produces a different hash from an empty body. Do not treat an absent body and {} as equivalent.", + "covers_divergence_point": [ + "empty_body" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "POST", + "path": "/api/orders", + "query": "", + "body": "{}" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nPOST\n/api/orders\n\n44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "body_hash": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "signature": "iq333q+XPiJkmenMK0ghw8zl8SH/34bzw27gTfc3NY9vHhuh99Pm8MM2yTnW99xnGO/PDvE9eSYsqstlEzHWCw==" + }, + { + "id": "ed25519_get_trailing_slash", + "description": "GET where the path ends with a trailing slash. The path is included verbatim in the string to sign — the slash is never removed.", + "covers_divergence_point": [ + "trailing_slash" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders/", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders/\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "GbfUpPdTmPVxih7to9mO9gwlttW+PkCzENkpGNAlH2WbMemSp7j3MciSUgGXwJc5qBqIoBUTSrtVu524OhQpBQ==" + }, + { + "id": "ed25519_get_no_trailing_slash", + "description": "GET on the same logical resource without a trailing slash. The string to signs for this vector and ed25519_get_trailing_slash differ because paths are sent verbatim.", + "covers_divergence_point": [ + "trailing_slash" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "M9p5nsCH7Fsre1D+RFzPKXk4eViL1YeaAteVY8L4nDXJz81jdYIQ1SBNjcYHUFzqcDxLQn0n5GhF4DPmjMe1Cw==" + }, + { + "id": "ed25519_get_path_not_decoded", + "description": "GET where the path contains a percent-encoded slash (%2F). The string to sign includes the path exactly as sent. The client must never percent-decode the path before canonicalizing.", + "covers_divergence_point": [ + "path_not_decoded" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/v1/items/foo%2Fbar", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/v1/items/foo%2Fbar\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "7O2wnFJ/k2JLt9DW4JZcl807ChV7vLQJFu9sSA/jmtbgGca1fdAs9y6DFaxPe4WnbBC8Wpjdez1MlKFUQFy3AQ==" + }, + { + "id": "ed25519_get_simple_query", + "description": "GET with two query parameters in reverse alphabetical order in the input. The canonical query must sort them bytewise by encoded name.", + "covers_divergence_point": [], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders", + "query": "status=open&limit=50", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders\nlimit=50&status=open\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "D9/FzREoDCsoQwdcwUB2TnINgSoE+axZdcP8f/UbpCFd++Rdn4vmUhRtEKXHxKIT3YiFsvfZoMLBv/RN04yGCA==" + }, + { + "id": "ed25519_get_query_sort_by_name", + "description": "GET with parameters whose encoded names require bytewise sort. 'Z' (0x5A) sorts before 'a' (0x61) in bytewise comparison. The canonical query here has 'Zebra' before 'apple'.", + "covers_divergence_point": [ + "sort_by_name_then_value" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "apple=1&Zebra=2", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\nZebra=2&apple=1\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "RUR6E1RBqhNzRxaP788pQJk3jj+EsQK1Sz1kwCm4YStL6TX849LFrU8913BzikbpKONMRgYMGU8rTNyv6HJoAw==" + }, + { + "id": "ed25519_get_duplicate_query_params", + "description": "GET where the same parameter name appears twice with different values. The canonical query sorts by name, then by value as a tiebreaker. Both occurrences are preserved; neither is dropped.", + "covers_divergence_point": [ + "duplicate_query_params", + "sort_by_name_then_value" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders", + "query": "status=open&status=closed", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders\nstatus=closed&status=open\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "xX0BasdE9P3GDi7D5NKc8+iRdIJRUz+vX77L41d6a2Ukei6gnjwLc/s0yw5h0NejpXdsN6Lw5w1w82uymUSODw==" + }, + { + "id": "ed25519_get_triple_duplicate_param", + "description": "GET with three occurrences of the same parameter name. All three are preserved and sorted by value.", + "covers_divergence_point": [ + "duplicate_query_params", + "sort_by_name_then_value" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/markets", + "query": "league=nfl&league=nba&league=mlb", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/markets\nleague=mlb&league=nba&league=nfl\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "AfcLvixPt6+mJY3Qf/1BM7JEs3HAhLkqxtgkQMOP8KZl7AUOA4p5Z1xVeQXGIK5zXn20gK54PJyovtjcWV/CBA==" + }, + { + "id": "ed25519_get_space_as_percent20", + "description": "GET where a query value contains a space encoded as %20. The canonical form keeps it as %20. A client that encodes the same space as '+' would produce a different string to sign and a verification failure.", + "covers_divergence_point": [ + "plus_vs_percent20" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "q=New%20York", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\nq=New%20York\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "TML2dP2JQDNDcqG1JIpVj+w0m9U07L6X5ExxZXQHs/I8fjIbHAssk5lbtrLnBaKO1Ci4/+yONsmQ/NOdyFIADw==" + }, + { + "id": "ed25519_get_bare_plus_not_decoded_as_space", + "description": "GET where the raw query string contains a bare '+' (not percent-encoded). RFC 3986 does not give '+' special meaning in query strings. A correct implementation decodes '+' as a literal '+' and re-encodes it as '%2B'. A buggy implementation using form-decoding (URLSearchParams, urllib.parse.parse_qs, etc.) would decode '+' as a space and produce 'q=foo%20bar' — a different string to sign that causes a verification failure. Both 'q=foo+bar' (bare) and 'q=foo%2Bbar' (pre-encoded) must produce 'q=foo%2Bbar'.", + "covers_divergence_point": [ + "plus_vs_percent20" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "q=foo+bar", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\nq=foo%2Bbar\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "UK3xQTFq9tPqWmBmuBxBAnnbfKnZI0PjEWbN/GkXMd+EkC+74//J3a81BNI9KNmIVvwB8+xrQhRU1wUL+ioyCg==" + }, + { + "id": "ed25519_get_preencoded_plus_roundtrip", + "description": "GET where the raw query string contains '%2B' (the percent-encoded form of '+'). Decode('%2B') = '+', then re-encode('+') = '%2B'. This vector and ed25519_get_bare_plus_not_decoded_as_space must produce the same canonical query: 'q=foo%2Bbar'.", + "covers_divergence_point": [ + "plus_vs_percent20" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "q=foo%2Bbar", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\nq=foo%2Bbar\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "UK3xQTFq9tPqWmBmuBxBAnnbfKnZI0PjEWbN/GkXMd+EkC+74//J3a81BNI9KNmIVvwB8+xrQhRU1wUL+ioyCg==" + }, + { + "id": "ed25519_get_lowercase_hex_normalised", + "description": "GET where the input query contains lowercase percent-encoding (%2f). The canonical form normalises to uppercase hex (%2F). The decoded-then-re-encoded value is the same regardless of the input casing.", + "covers_divergence_point": [ + "hex_case" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "path=%2ftmp%2ffile", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\npath=%2Ftmp%2Ffile\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "PlaH9D3dPJqAfB/PH3JN6/rpGYlSToVRiSq48zeJM9OmFyxu/xhLox0rHZ2/4rfM91WuYsGqV9/KJH4vWFLZAQ==" + }, + { + "id": "ed25519_get_non_ascii_query_value", + "description": "GET where a query value contains a non-ASCII character (ü, U+00FC). The value arrives percent-encoded as %C3%BC (UTF-8, uppercase hex). The canonical query decodes and re-encodes it, producing %C3%BC. A client sending %c3%bc (lowercase) would also canonicalize to %C3%BC.", + "covers_divergence_point": [ + "non_ascii_value" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "lang=%C3%BC", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\nlang=%C3%BC\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "YUKyRzueHvdyXe4GjaLHl6vMhREdl3AxcAIQLjwAV7GPzv7QncVigqvzjg2HAidXlXQJcTWhMDSVrFOZmfCmAw==" + }, + { + "id": "ed25519_get_emoji_query", + "description": "GET where both the query name and value contain non-ASCII characters. The emoji U+1F600 (😀) encodes as %F0%9F%98%80 in UTF-8.", + "covers_divergence_point": [ + "non_ascii_value" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/events", + "query": "emoji=%F0%9F%98%80", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/events\nemoji=%F0%9F%98%80\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "HSH1hJ+f0MelEmn3lIHpC2NgnyEyzNiSZ7k2ccH7lT1E4MF35Xnlv8s3bYseOvRWeHyFVKjlnO4p8+YMFkaACg==" + }, + { + "id": "ed25519_put_with_body_and_query", + "description": "PUT with a JSON body and a query string. Exercises the full string to sign: all six lines are non-trivial.", + "covers_divergence_point": [], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000001, + "method": "PUT", + "path": "/api/orders/ord_abc123", + "query": "dry_run=false", + "body": "{\"price\":\"0.60\",\"size\":\"50\"}" + }, + "string_to_sign": "NOVIG-V3\n1755000000001\nPUT\n/api/orders/ord_abc123\ndry_run=false\n21178b55526b358c39816cbdfeb5e521fb80b474dcf0acf752228eb898377293", + "body_hash": "21178b55526b358c39816cbdfeb5e521fb80b474dcf0acf752228eb898377293", + "signature": "2QRbkXNnb/tI8jzfQlEh8gduLLaJJjOjAlx7jjUvikKJXkAKoh7nmypARuZNI9Dy41Esz/XLzhz9nwlmtNNyBg==" + }, + { + "id": "ed25519_delete_no_body", + "description": "DELETE with no query string and no body.", + "covers_divergence_point": [ + "empty_body", + "absent_query" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000002, + "method": "DELETE", + "path": "/api/orders/ord_abc123", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000002\nDELETE\n/api/orders/ord_abc123\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "5AmCB2+JXuNNXQfZhLeJgfnjf51WL3u8kafAbZHNKDLaCOTjqiAsqkiQftYwjdJh1v04XkM2xhg0RWU39rcfCw==" + }, + { + "id": "ed25519_patch_body", + "description": "PATCH method. All HTTP methods are uppercase in the string to sign.", + "covers_divergence_point": [], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000003, + "method": "PATCH", + "path": "/api/keys/key_xyz", + "query": "", + "body": "{\"name\":\"production-bot\"}" + }, + "string_to_sign": "NOVIG-V3\n1755000000003\nPATCH\n/api/keys/key_xyz\n\nbbe7e1be0f13c9f393f4add8de4a05cda2d73e75f9c9dbc2dab9867b22118091", + "body_hash": "bbe7e1be0f13c9f393f4add8de4a05cda2d73e75f9c9dbc2dab9867b22118091", + "signature": "HVkf3LG3Go01RffqmM/VgKoiJbw4DCPPJK8WGbz7iZ/iW4Pgc4hLCd1G1vJnZO/mtxh5gxsbeBYU1NrTYg9lBg==" + }, + { + "id": "ed25519_get_equals_in_query_value", + "description": "GET where a query value contains a literal '=' character (common in base64 padding). The spec splits each pair at the FIRST '=' only. For 'token=abc=', name is 'token' and value is 'abc='. The '=' in the value is not an unreserved RFC 3986 character, so it is encoded as '%3D'. Canonical query: 'token=abc%3D'.", + "covers_divergence_point": [ + "equals_in_query_value" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/keys", + "query": "token=abc=", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/keys\ntoken=abc%3D\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "ldR9iLkKsoZV0NWXmXFoDs9nfBxNItjKAM7GUjHr/fVqQqkipQpUQgCsKzc66rfpw0Ojs1FkAsr1TZzGPhWnCQ==" + }, + { + "id": "ed25519_get_bare_query_param", + "description": "GET with a query parameter that has no '=' and no value (e.g. '?flag'). The spec splits at the first '=': when no '=' is present, the name is the whole token and the value is the empty string. Canonical form: 'flag='.", + "covers_divergence_point": [ + "bare_query_param" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders", + "query": "flag", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders\nflag=\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "oXNBKFrTmPDdPCdewCGLHgUxAym6wZ1sovrhwELpUhiFudd//74ZmiJHgkWPhNHuGEfalEAYLqub+Fn3mQIaBQ==" + }, + { + "id": "ed25519_post_large_body", + "description": "POST with a larger JSON body, exercising the SHA-256 path with non-trivial input.", + "covers_divergence_point": [], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000004, + "method": "POST", + "path": "/api/orders/batch", + "query": "", + "body": "{\"orders\":[{\"side\":\"BUY\",\"price\":\"0.55\",\"size\":\"100\",\"market_id\":\"m1\",\"client_order_id\":\"co-1\"},{\"side\":\"SELL\",\"price\":\"0.60\",\"size\":\"200\",\"market_id\":\"m2\",\"client_order_id\":\"co-2\"},{\"side\":\"BUY\",\"price\":\"0.45\",\"size\":\"500\",\"market_id\":\"m3\",\"client_order_id\":\"co-3\"}]}" + }, + "string_to_sign": "NOVIG-V3\n1755000000004\nPOST\n/api/orders/batch\n\n955e06d8eff25b5e024e114fbc67bb5b05e9267444e8fbf05c5662cb56247f56", + "body_hash": "955e06d8eff25b5e024e114fbc67bb5b05e9267444e8fbf05c5662cb56247f56", + "signature": "54T/G2ZUDHP4lR8x+ptTEDsNmANoSueUF+BdfRuX8OP12gl1xSnC5BIP7P0vtHlf1cmEheykiCcG2dbhKopPDA==" + }, + { + "id": "ed25519_get_tilde_unreserved", + "description": "GET where a query value contains '~', once pre-encoded as %7E and once bare. RFC 3986 lists '~' as unreserved, so the canonical form keeps it bare in both places. A verifier that encodes '~' as %7E produces a different string to sign and a verification failure.", + "covers_divergence_point": [ + "tilde_unreserved" + ], + "algorithm": "ed25519", + "keypair_id": "ed25519-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "path=%7Ehome&tag=a~b", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\npath=~home&tag=a~b\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "t7pSHUNCi0fvAUngSSnRDxLn6r8GJ26wkeBeMvgtBDo+Sr22POME/u8luzxEnhKJvIhCxVjNd+FPwAEiIRiMBA==" + }, + { + "id": "ecdsa_p256_get_absent_query_empty_body", + "description": "ECDSA P-256 equivalent of ed25519_get_absent_query_empty_body. The string to sign is identical; only the algorithm and signature differ. The ECDSA signature is a DER-encoded ASN.1 SEQUENCE of two INTEGERs (r, s). It is base64-encoded using standard (padded) base64.", + "covers_divergence_point": [ + "absent_query", + "empty_body" + ], + "algorithm": "ecdsa-p256", + "keypair_id": "ecdsa-p256-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders/all", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders/all\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "MEYCIQDaOiI6d9t71y9XHnHNRu144p+Gwvk4Xkh6yUpCSP9XfwIhAKqiOUFyCC7EeSHH0QF14UhBI3cRG/UW74GUbwVqmwgc" + }, + { + "id": "ecdsa_p256_post_json_body", + "description": "ECDSA P-256 equivalent of ed25519_post_json_body.", + "covers_divergence_point": [], + "algorithm": "ecdsa-p256", + "keypair_id": "ecdsa-p256-test-1", + "input": { + "timestamp": 1755000000000, + "method": "POST", + "path": "/api/orders", + "query": "", + "body": "{\"side\":\"BUY\",\"price\":\"0.55\",\"size\":\"100\",\"market_id\":\"abc123\"}" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nPOST\n/api/orders\n\n3d8f6e268d1ac3ab4943df1c1e1a985e0f1d411327e364d1ab147ea6e9c26936", + "body_hash": "3d8f6e268d1ac3ab4943df1c1e1a985e0f1d411327e364d1ab147ea6e9c26936", + "signature": "MEUCIQCSfmZPXLUzYoT3oRbf6F7lQkcia0N7vvfokvjzYc5ZrwIgJ+51IiK6ftKMNw7LhkMAS4LEeJtx6ON0lP3vfk+QFZs=" + }, + { + "id": "ecdsa_p256_get_trailing_slash", + "description": "ECDSA P-256 equivalent of ed25519_get_trailing_slash. The trailing slash is preserved in the canonical path.", + "covers_divergence_point": [ + "trailing_slash" + ], + "algorithm": "ecdsa-p256", + "keypair_id": "ecdsa-p256-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders/", + "query": "", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders/\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "MEYCIQDE+vowH6NqoptDBU/72CmlVIiaNrZ8tYlatSm41T/ICwIhAIp/P0m8tVUa0er5Pnv5VxqUkmPle45KFc7FN8L48JQu" + }, + { + "id": "ecdsa_p256_get_duplicate_query_params", + "description": "ECDSA P-256 equivalent of ed25519_get_duplicate_query_params.", + "covers_divergence_point": [ + "duplicate_query_params", + "sort_by_name_then_value" + ], + "algorithm": "ecdsa-p256", + "keypair_id": "ecdsa-p256-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/orders", + "query": "status=open&status=closed", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/orders\nstatus=closed&status=open\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "MEUCIB5crYErCSI6dLiTg+QUTR1jsc/P2oQYSd0DQh/dsxFrAiEA2oS4fhZnjaDN5fKvuD4/Ez3wmu9vwq2Z2OssU+uUKY8=" + }, + { + "id": "ecdsa_p256_get_non_ascii_query_value", + "description": "ECDSA P-256 equivalent of ed25519_get_non_ascii_query_value.", + "covers_divergence_point": [ + "non_ascii_value" + ], + "algorithm": "ecdsa-p256", + "keypair_id": "ecdsa-p256-test-1", + "input": { + "timestamp": 1755000000000, + "method": "GET", + "path": "/api/search", + "query": "lang=%C3%BC", + "body": "" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nGET\n/api/search\nlang=%C3%BC\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "MEUCIQDn4c7KlVFKUt+5/gx9rhkzOxTMc56FaMaCfmUJnqarWQIgGvzN09Zkq8iJBrnR1vFCYwdgHPOla4pbf/RHkplTF98=" + }, + { + "id": "ecdsa_p256_post_empty_json_object", + "description": "ECDSA P-256 equivalent of ed25519_post_empty_json_object.", + "covers_divergence_point": [ + "empty_body" + ], + "algorithm": "ecdsa-p256", + "keypair_id": "ecdsa-p256-test-1", + "input": { + "timestamp": 1755000000000, + "method": "POST", + "path": "/api/orders", + "query": "", + "body": "{}" + }, + "string_to_sign": "NOVIG-V3\n1755000000000\nPOST\n/api/orders\n\n44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "body_hash": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "signature": "MEQCIDlZiaDdRyKhoP2sv51iinSOl6ZSAqHDTmYyxgiAWrPCAiAyeRrgdgVfwQtKw1BNJyWgHwszFFw2x5SOFXbaS/owZQ==" + } + ] +} diff --git a/tests/samples/novig_trades.json b/tests/samples/novig_trades.json new file mode 100644 index 0000000..3b68c95 --- /dev/null +++ b/tests/samples/novig_trades.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "tradeId": "01a16989-52a3-7851-9476-710af122b988", + "takeId": "01a16989-52a3-7851-9476-70d62bb00fab", + "outcomeId": "01a1577c-d666-7e81-852e-68d273380247", + "price": "0.370", + "qty": 8030, + "ts": 1792771969699 + }, + { + "tradeId": "01a16989-52a3-7851-9476-70e66942720a", + "takeId": "01a16989-52a3-7851-9476-70d62bb00fab", + "outcomeId": "01a1577c-d666-7e81-852e-68d273380247", + "price": "0.370", + "qty": 18954, + "ts": 1792771969699 + }, + { + "tradeId": "01a16989-21b2-7f81-b3c3-ab95020e30a5", + "takeId": "01a16989-21b2-7f81-b3c3-ab83ad04189a", + "outcomeId": "01a1577c-d666-7e81-852e-68d273380247", + "price": "0.370", + "qty": 317, + "ts": 1792771957170 + }, + { + "tradeId": "01a16988-dfa6-7210-9cee-b44b766e0983", + "takeId": "01a16988-dfa6-7210-9cee-b43b5cc14eea", + "outcomeId": "01a1577c-d666-7e81-852e-68ce96100d9c", + "price": "0.625", + "qty": 2666, + "ts": 1792771940262 + }, + { + "tradeId": "01a16988-9783-7a12-8871-c9c16b26ef52", + "takeId": "01a16988-9783-7a12-8871-c9561c91699d", + "outcomeId": "01a1577c-d666-7e81-852e-68d273380247", + "price": "0.370", + "qty": 48296, + "ts": 1792771921795 + }, + { + "tradeId": "01a16988-9783-7a12-8871-c9afee7d0ba0", + "takeId": "01a16988-9783-7a12-8871-c9561c91699d", + "outcomeId": "01a1577c-d666-7e81-852e-68d273380247", + "price": "0.370", + "qty": 27027, + "ts": 1792771921795 + } + ], + "next": "01a16988-9783-7a12-8871-c9afee7d0ba0" +} \ No newline at end of file diff --git a/tests/test_init.py b/tests/test_init.py index 775d89a..298cff1 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -42,7 +42,8 @@ def test_it_writes_every_venue_asked_for_readable_by_the_owner_only(tmp_path: Pa "n", # Limitless "n", # SX Bet "n", # Betfair - "n"], # Smarkets + "n", # Smarkets + "n"], # Novig secrets=[KEY, "us-secret"], ) assert run(env, p) == 0 @@ -64,7 +65,7 @@ def test_wrong_values_are_asked_again_with_the_reason(tmp_path: Path): p, said = scripted( ["y", "", "kid-1", str(tmp_path / "missing.pem"), str(not_pem), str(pem), "staging", "demo", "y", "0x1234", WALLET, "7", "1", - "n", "n", "n", "n", "n", "n", "n", "n"], + "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -81,7 +82,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) env = tmp_path / ".env" env.write_text("# mine\nSOMETHING_ELSE=keep\nPOLYMARKET_US_KEY_ID=old\n") os.chmod(env, 0o644) - p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) + p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) assert run(env, p) == 0 text = env.read_text() assert "# mine" in text and "SOMETHING_ELSE=keep" in text @@ -91,7 +92,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) def test_saying_no_to_everything_writes_nothing(tmp_path: Path): - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) assert run(tmp_path / ".env", p) == 0 assert not (tmp_path / ".env").exists() and any("Nothing changed." in line for line in said) @@ -104,7 +105,7 @@ def test_merge_quotes_values_with_spaces_and_updates_in_place(): @pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) + p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) assert run(tmp_path / ".env", p) == 0 assert (tmp_path / ".gitignore").read_text() == ".env\n" ignored = subprocess.run(["git", "check-ignore", "-q", ".env"], cwd=tmp_path) @@ -113,7 +114,7 @@ def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): def test_opinion_takes_a_key_it_is_given(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["OPINION_PRIVATE_KEY"] == KEY and values["OPINION_API_KEY"] == "opinion-api-key" @@ -122,7 +123,7 @@ def test_opinion_takes_a_key_it_is_given(tmp_path: Path): def test_opinion_with_an_api_key_needs_no_private_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"OPINION_API_KEY": "opinion-api-key"} assert load_credentials({}, dotenv=env, redact_logs=False)["opinion"] is None, "not enough to trade" @@ -135,7 +136,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch signed_with: list[str] = [] monkeypatch.setattr(opinion, "create_api_key", lambda key: signed_with.append(key) or "created-key") - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) assert run(tmp_path / ".env", p) == 0 assert signed_with == [KEY], "creating a key needs the private key: an empty one is asked again" assert read_dotenv(tmp_path / ".env")["OPINION_API_KEY"] == "created-key" @@ -144,7 +145,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY and values["HYPERLIQUID_ACCOUNT_ADDRESS"] == WALLET @@ -156,7 +157,7 @@ def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY @@ -165,7 +166,7 @@ def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_PRIVATE_KEY"] == KEY and values["PREDICT_FUN_ACCOUNT_ADDRESS"] == WALLET @@ -178,7 +179,7 @@ def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"PREDICT_FUN_API_KEY": "pred_sk_test"} text = " ".join(said) @@ -190,7 +191,7 @@ def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_TESTNET"] == "1" and "PREDICT_FUN_API_KEY" not in values @@ -201,7 +202,7 @@ def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): env = tmp_path / ".env" secret = "c2VjcmV0LWJ5dGVz" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n"], secrets=[KEY[2:], secret]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) assert run(env, p) == 0 values = read_dotenv(env) assert values["LIMITLESS_PRIVATE_KEY"] == KEY and values["LIMITLESS_API_TOKEN_ID"] == "token-123" @@ -212,7 +213,7 @@ def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n"], secrets=["sx-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["SX_BET_PRIVATE_KEY"] == KEY and values["SX_BET_API_KEY"] == "sx-api-key" @@ -223,7 +224,7 @@ def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n"], secrets=["sx-api-key", ""]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key", "SX_BET_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said), "a key pasted as the address is refused" @@ -234,7 +235,7 @@ def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): def test_sx_bet_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n"], secrets=["sx-api-key", ""]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key"} loaded = load_credentials({}, dotenv=env, redact_logs=False)["sx_bet"] diff --git a/tests/test_novig.py b/tests/test_novig.py new file mode 100644 index 0000000..9eef0e2 --- /dev/null +++ b/tests/test_novig.py @@ -0,0 +1,347 @@ +"""Novig normalizers and adapter, against payloads recorded from the live +venue's public catalog. Every request goes to an `httpx.MockTransport` +answering from the samples.""" +from __future__ import annotations + +import re + +import httpx +import pytest + +from synpath import BadRequest, MarketNotFound, NotSupported +from synpath.novig import ( + Novig, fee_schedule_of, normalize_event, normalize_market, normalize_order_book, normalize_trade, quotes_of, + result_of, side_ids, status_of, +) + +from conftest import load + +EVENTS = load("novig_events.json") +EVENT = load("novig_event.json") +EVENT_MARKETS = load("novig_event_markets.json")["items"] +MARKETS = load("novig_markets.json") +MARKET = load("novig_market.json") +BOOK = load("novig_book.json") +TRADES = load("novig_trades.json") + +MIL = MARKET["marketId"] +MIL_EVENT = next(e for e in EVENTS["items"] if e["eventId"] == MARKET["eventId"]) +YES, NO = sorted(o["outcomeId"] for o in MARKET["outcomes"]) +ALL_MARKETS = [*MARKETS["items"], *EVENT_MARKETS] + + +class Api: + """The public catalog, answered from the samples. Records every request.""" + + def __init__(self, overrides: dict | None = None): + self.requests: list[httpx.Request] = [] + self.overrides = overrides or {} + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + for pattern, answer in self.overrides.items(): + if re.fullmatch(pattern, path): + return answer(request) if callable(answer) else answer + params = request.url.params + if path == "/v3/public/catalog/events": + return httpx.Response(200, json=EVENTS if not params.get("after") else {"items": []}) + if m := re.fullmatch(r"/v3/public/catalog/events/([^/]+)", path): + found = [e for e in EVENTS["items"] if e["eventId"] == m.group(1)] + return httpx.Response(200, json=found[0]) if found else not_found("EVENT_NOT_FOUND") + if path == "/v3/public/catalog/markets": + if params.get("event"): + return httpx.Response(200, json={"items": [x for x in ALL_MARKETS if x["eventId"] == params["event"]]}) + return httpx.Response(200, json=MARKETS) + if m := re.fullmatch(r"/v3/public/catalog/markets/([^/]+)/book", path): + return httpx.Response(200, json=BOOK) if m.group(1) == MIL else httpx.Response( + 200, json={"marketId": m.group(1), "seq": 0, "orders": {}}) + if m := re.fullmatch(r"/v3/public/catalog/markets/([^/]+)/trades", path): + first = m.group(1) == MIL and not params.get("after") + return httpx.Response(200, json=TRADES if first else {"items": []}) + if m := re.fullmatch(r"/v3/public/catalog/markets/([^/]+)", path): + found = [x for x in ALL_MARKETS if x["marketId"] == m.group(1)] + return httpx.Response(200, json=found[0]) if found else not_found("MARKET_NOT_FOUND") + return not_found("NOT_FOUND") + + def paths(self) -> list[str]: + return [r.url.path for r in self.requests] + + +def not_found(code: str) -> httpx.Response: + return httpx.Response(404, json={"code": code, "message": "Not found"}) + + +def venue(api: Api | None = None, **kwargs) -> tuple[Novig, Api]: + api = api or Api() + return Novig(limiter=None, client=httpx.Client(transport=httpx.MockTransport(api.handler)), **kwargs), api + + +def market_of_type(kind: str) -> dict: + return next(m for m in ALL_MARKETS if m["marketType"] == kind) + + +class TestMarket: + def test_a_market_is_named_by_its_id_and_its_event(self): + market = normalize_market(MARKET, MIL_EVENT) + assert market.id == f"novig:{MIL}" and market.venue_market_id == MIL + assert market.event_id == f"novig:{MARKET['eventId']}" + assert market.title == f"{MIL_EVENT['description']} — {MARKET['description']}" + assert market.book_model == "shared_complement" and market.face_value == 1.0 + + def test_the_outcome_listed_first_is_yes(self): + market = normalize_market(MARKET, MIL_EVENT) + assert side_ids(MARKET) == (YES, NO) + assert market.yes.venue_token_id == YES and market.no.venue_token_id == NO + first = next(o for o in MARKET["outcomes"] if o["outcomeId"] == YES) + assert market.yes.label == first["name"] and market.outcome_label == first["name"] + + def test_the_order_of_the_outcomes_array_does_not_matter(self): + flipped = {**MARKET, "outcomes": list(reversed(MARKET["outcomes"]))} + assert side_ids(flipped) == (YES, NO) + + def test_a_total_is_over_against_under(self): + total = normalize_market(market_of_type("TOTAL"), EVENT) + assert total.yes.label.startswith("Over") and total.no.label.startswith("Under") + assert total.info["strike"] == market_of_type("TOTAL")["strike"] + + def test_a_prop_is_yes_against_no(self): + prop = next(m for m in ALL_MARKETS if [o["name"] for o in m["outcomes"]] == ["Yes", "No"]) + market = normalize_market(prop) + assert (market.yes.label, market.no.label) == ("Yes", "No") + + def test_quotes_are_each_outcomes_best_bid(self): + yes, no = quotes_of(MARKET) + bids = {o["outcomeId"]: o.get("bid") for o in MARKET["outcomes"]} + assert yes.bid == float(bids[YES]["price"]) and yes.bid_size == bids[YES]["qty"] / 100 + assert yes.ask == round(1 - float(bids[NO]["price"]), 6) and yes.ask_size == bids[NO]["qty"] / 100 + assert no.bid == round(1 - yes.ask, 6) and no.ask == round(1 - yes.bid, 6) + last = next(o for o in MARKET["outcomes"] if o["outcomeId"] == YES)["last"] + assert yes.last == float(last) and no.last == round(1 - float(last), 6) + + def test_an_outcome_without_bids_leaves_that_side_empty(self): + bare = {**MARKET, "outcomes": [{k: v for k, v in o.items() if k != "bid"} for o in MARKET["outcomes"]]} + yes, no = quotes_of(bare) + assert yes.bid is None and yes.ask is None and yes.mid is None and no.bid is None + + def test_volume_and_open_interest_are_dollar_contracts(self): + market = normalize_market(MARKET, MIL_EVENT) + assert market.stats.volume_total == MARKET["volume"] / 100 + assert market.stats.open_interest == MARKET["openInterest"] / 100 + assert market.stats.volume_unit == "contracts" and market.info["volume_qty"] == MARKET["volume"] + + def test_sport_and_league_are_the_tags(self): + market = normalize_market(MARKET, MIL_EVENT) + assert market.tags == [MIL_EVENT["sport"].lower(), MIL_EVENT["league"].lower()] + assert market.category == MIL_EVENT["sport"].lower() + + @pytest.mark.parametrize("market,event,expected", [ + ("OPEN", "OPEN_PREGAME", ("open", True)), ("OPEN", "OPEN_INGAME", ("open", True)), + ("OPEN", "DELAYED", ("open", True)), ("OPEN", "CLOSED_PREGAME", ("open", False)), + ("OPEN", "FINAL", ("closed", False)), ("OPEN", "CANCELED", ("closed", False)), + ("CLOSED", "OPEN_INGAME", ("closed", False)), ("SETTLED", "FINAL", ("settled", False)), + ]) + def test_status_of(self, market, event, expected): + assert status_of(market, event) == expected + + @pytest.mark.parametrize("yes,no,expected", [ + ("TBD", "TBD", (None, None)), ("WIN", "LOSS", ("yes", None)), ("LOSS", "WIN", ("no", None)), + ("PUSH", "PUSH", ("void", None)), ("0.420", "0.580", ("void", "0.420")), + ]) + def test_grades(self, yes, no, expected): + graded = {**MARKET, "outcomes": [{**o, "status": yes if o["outcomeId"] == YES else no} for o in MARKET["outcomes"]]} + assert result_of(graded) == expected + + def test_an_in_play_event(self): + live = next(e for e in EVENTS["items"] if e["status"] == "OPEN_INGAME") + market = normalize_market(MARKET, live) + assert market.info["in_play"] and market.active and market.native_status == "OPEN/OPEN_INGAME" + + +class TestEvent: + def test_an_event_holds_its_markets(self): + event = normalize_event(EVENT, EVENT_MARKETS) + assert event.id == f"novig:{EVENT['eventId']}" and event.title == EVENT["description"] + assert [m.id for m in event.markets] == [f"novig:{m['marketId']}" for m in EVENT_MARKETS] + assert event.status == "open" and event.mutually_exclusive is None + assert event.info["league"] == EVENT["league"] + + def test_a_finished_event_is_closed(self): + final = next(e for e in EVENTS["items"] if e["status"] == "FINAL") + assert normalize_event(final, [{**MARKET, "eventId": final["eventId"]}]).status == "closed" + + +class TestBook: + def test_yes_bids_and_no_bids_as_asks(self): + book = normalize_order_book(BOOK, yes_outcome=YES, no_outcome=NO, market_id=f"novig:{MIL}") + yes_prices = {float(o["price"]) for o in BOOK["orders"][YES]} + no_prices = {round(1 - float(o["price"]), 6) for o in BOOK["orders"][NO]} + assert {level.price for level in book.bids} == yes_prices + assert {level.price for level in book.asks} == no_prices + assert book.bids == sorted(book.bids, key=lambda level: -level.price) + assert book.asks == sorted(book.asks, key=lambda level: level.price) + assert book.best_bid.price < book.best_ask.price + + def test_orders_at_one_price_are_summed(self): + book = normalize_order_book(BOOK, yes_outcome=YES, no_outcome=NO, market_id=f"novig:{MIL}") + top = book.bids[0].price + assert book.bids[0].size == round(sum(o["qty"] for o in BOOK["orders"][YES] if float(o["price"]) == top) / 100, 6) + + def test_the_no_side_mirrors_it(self): + yes = normalize_order_book(BOOK, yes_outcome=YES, no_outcome=NO, market_id=f"novig:{MIL}") + no = normalize_order_book(BOOK, yes_outcome=YES, no_outcome=NO, market_id=f"novig:{MIL}", side="no") + assert no.derived and no.bids[0].price == round(1 - yes.asks[0].price, 6) + assert no.asks[0].size == yes.bids[0].size + + def test_an_unknown_side_is_refused(self): + with pytest.raises(BadRequest): + normalize_order_book(BOOK, yes_outcome=YES, no_outcome=NO, market_id="x", side="maybe") # type: ignore[arg-type] + + +class TestTrades: + def test_a_print_on_yes_is_a_taker_selling_yes(self): + row = next(r for r in TRADES["items"] if r["outcomeId"] == YES) + trade = normalize_trade(row, yes_outcome=YES, market_id=f"novig:{MIL}") + assert trade.side == "sell" and trade.price == float(row["price"]) + assert trade.quantity == row["qty"] / 100 and trade.id == row["tradeId"] + + def test_a_print_on_no_is_a_taker_buying_yes(self): + rows = [r for r in TRADES["items"] if r["outcomeId"] == NO] + if not rows: + rows = [{**TRADES["items"][0], "outcomeId": NO, "price": "0.370"}] + trade = normalize_trade(rows[0], yes_outcome=YES, market_id=f"novig:{MIL}") + assert trade.side == "buy" and trade.price == round(1 - float(rows[0]["price"]), 6) + + +class TestFees: + def test_the_markets_own_coefficient_and_maker_credit(self): + schedule = fee_schedule_of(MARKET) + coefficient, credit = float(MARKET["fee"]["coefficient"]), float(MARKET["fee"]["makerCredit"]) + assert schedule.fee_type == "quadratic_theta" and schedule.taker_rate == coefficient + assert schedule.maker_rate == -round(coefficient * credit, 9) + assert schedule.info["charged"] == MARKET["fee"]["charged"] + + def test_the_docs_worked_examples(self): + # 10,000 venue contracts (100 dollar contracts) at 0.50: $1.50; at 0.30: $1.26. + schedule = fee_schedule_of({"marketId": "m", "fee": {"coefficient": "0.06", "makerCredit": "0.25", + "charged": "WHEN_LIVE"}}) + assert schedule.estimate(0.5, 100) == 1.5 and schedule.estimate(0.3, 100) == 1.26 + assert schedule.estimate(0.5, 100, taker=False) == -0.375 + + +class TestAdapter: + def test_fetch_markets_is_one_page_with_titles(self): + nv, api = venue() + page = nv.fetch_markets(limit=10) + assert [m.id for m in page] == [f"novig:{m['marketId']}" for m in MARKETS["items"]] + assert page.next_cursor == MARKETS["next"] + first = api.requests[0] + assert first.url.path == "/v3/public/catalog/markets" and first.url.params["limit"] == "10" + assert first.url.params["eventStatus"] == "OPEN_PREGAME,OPEN_INGAME,DELAYED,CLOSED_PREGAME" + assert page[0].title.startswith(MIL_EVENT["description"]) + + def test_filters_are_passed_through(self): + nv, api = venue() + nv.fetch_markets(league=["NFL", "NBA"], market_type="MONEY", cursor="abc", status="closed") + params = api.requests[0].url.params + assert (params["league"], params["marketType"], params["after"]) == ("NFL,NBA", "MONEY", "abc") + assert params["eventStatus"] == "FINAL,CANCELED" + + def test_settled_listings_are_not_in_the_public_catalog(self): + nv, _ = venue() + with pytest.raises(NotSupported): + nv.fetch_markets(status="settled") + nv.fetch_events(status="all") + + def test_a_search_matches_event_descriptions(self): + nv, api = venue() + page = nv.fetch_markets(query="maple leafs") + assert page and all(m.event_id == f"novig:{EVENT['eventId']}" for m in page) + assert any(r.url.params.get("event") == EVENT["eventId"] for r in api.requests) + + def test_no_sort(self): + nv, _ = venue() + with pytest.raises(NotSupported): + nv.fetch_markets(sort="volume") + + def test_fetch_events_reads_each_events_markets(self): + nv, api = venue() + page = nv.fetch_events(limit=2) + assert [e.id for e in page] == [f"novig:{e['eventId']}" for e in EVENTS["items"][:2]] + assert page.next_cursor == EVENTS["next"] + assert sum(1 for r in api.requests if r.url.params.get("event")) == 2 + + def test_fetch_market_and_its_outcomes_are_kept(self): + nv, api = venue() + market = nv.fetch_market(f"novig:{MIL}") + assert market.id == f"novig:{MIL}" and market.title.startswith(MIL_EVENT["description"]) + assert nv.outcome_ids(MIL) == (YES, NO) + before = len(api.requests) + nv.fetch_order_book(MIL) + assert len(api.requests) == before + 1 # the book only: the outcomes were known + + def test_an_unknown_market(self): + nv, _ = venue() + with pytest.raises(MarketNotFound, match="history"): + nv.fetch_market("novig:00000000-0000-0000-0000-000000000000") + assert nv.fetch_markets_by_ids(["novig:00000000-0000-0000-0000-000000000000", f"novig:{MIL}"])[0].id == f"novig:{MIL}" + + def test_another_venues_id_is_refused(self): + nv, _ = venue() + with pytest.raises(BadRequest): + nv.fetch_market("kalshi:KXFOO") + + def test_a_key_reads_a_closed_market_from_history(self): + settled = {**MARKET, "status": "SETTLED", "settledTs": 1792800000000, + "outcomes": [{**o, "status": "WIN" if o["outcomeId"] == YES else "LOSS"} for o in MARKET["outcomes"]]} + history: list[httpx.Request] = [] + + def signed(request: httpx.Request) -> httpx.Response: + history.append(request) + if request.url.path == f"/v3/history/markets/{MIL}": + return httpx.Response(200, json=settled) + return not_found("EVENT_NOT_FOUND") + + from test_novig_trading import credentials, verified + + api = Api({rf"/v3/public/catalog/markets/{MIL}": not_found("MARKET_NOT_FOUND")}) + nv, _ = venue(api, credentials=credentials(), signed_client=httpx.Client(transport=httpx.MockTransport(signed))) + market = nv.fetch_market(MIL) + assert market.status == "settled" and market.info["result"] == "yes" and market.resolution_timestamp + assert verified(history[0]) and history[0].url.host == "api.paper.novig.com" + + def test_book_depth_is_capped_at_twenty(self): + nv, api = venue() + book = nv.fetch_order_book(f"novig:{MIL}", depth=50) + assert api.requests[-1].url.params["depth"] == "20" and book.depth_scope == "top_n" + nv.fetch_order_book(f"novig:{MIL}", depth=1) + assert api.requests[-1].url.params["depth"] == "1" + + def test_fetch_trades_oldest_first(self): + nv, _ = venue() + trades = nv.fetch_trades(f"novig:{MIL}") + assert [t.timestamp for t in trades] == sorted(t.timestamp for t in trades) + assert trades.next_cursor == TRADES.get("next") + assert {t.side for t in trades} <= {"buy", "sell"} + + def test_candles_from_the_tape(self): + nv, _ = venue() + candles = nv.fetch_ohlcv(f"novig:{MIL}", timeframe="1m") + assert candles and all(c.price_source == "trade" for c in candles) + assert sum(c.trade_count for c in candles) == len(TRADES["items"]) + + def test_fee_schedule_reads_the_market(self): + nv, _ = venue() + assert nv.fetch_fee_schedule(f"novig:{MIL}").scope_id == MIL + + def test_paper_is_its_own_host(self): + nv = Novig(env="paper") + assert nv.http.base_url == "https://api.paper.novig.com" + with pytest.raises(BadRequest): + Novig(env="sandbox") + + def test_it_is_a_registered_venue(self): + import synpath + + assert synpath.exchanges["novig"] is Novig and "novig" in synpath.ids.VENUES + assert synpath.Novig.has["search"] == "partial" and synpath.Novig.has["create_order"] is False diff --git a/tests/test_novig_live.py b/tests/test_novig_live.py new file mode 100644 index 0000000..b561364 --- /dev/null +++ b/tests/test_novig_live.py @@ -0,0 +1,50 @@ +"""Novig against the real venue's public catalog: run with `pytest -m live`. +No key needed. Kept light: the edge throttles each IP.""" +from __future__ import annotations + +import pytest + +from synpath import Novig + +pytestmark = pytest.mark.live + + +@pytest.fixture(scope="module") +def venue(): + with Novig() as nv: + yield nv + + +@pytest.fixture(scope="module") +def markets(venue): + return venue.fetch_markets(market_type="MONEY", limit=20) + + +def test_the_catalog_is_open_and_named(markets): + assert markets and all(m.status in ("open", "closed") and m.yes.label and m.no.label for m in markets) + assert all(m.id.startswith("novig:") and m.event_id and m.title for m in markets) + + +def test_books_are_yes_and_never_crossed(venue, markets): + quoted = [m for m in markets if m.yes.quote.bid is not None][:5] + books = venue.fetch_order_books([m.id for m in quoted]) + assert books + for market in quoted: + book = books[market.id] + if book.bids and book.asks: + assert book.bids[0].price < book.asks[0].price + assert all(0 < level.price < 1 and level.size > 0 for level in book.bids + book.asks) + + +def test_the_tape_reads_oldest_first(venue, markets): + traded = next((m for m in markets if (m.stats.volume_total or 0) > 0), None) + if traded is None: + pytest.skip("nothing traded on the first page") + trades = venue.fetch_trades(traded.id, limit=20) + assert trades == sorted(trades, key=lambda t: t.timestamp) + assert all(0 < t.price < 1 and t.side in ("buy", "sell") for t in trades) + + +def test_paging_neither_repeats_nor_drops(venue, markets): + following = venue.fetch_markets(market_type="MONEY", limit=20, cursor=markets.next_cursor) + assert not {m.id for m in markets} & {m.id for m in following} diff --git a/tests/test_novig_trading.py b/tests/test_novig_trading.py new file mode 100644 index 0000000..33afaad --- /dev/null +++ b/tests/test_novig_trading.py @@ -0,0 +1,600 @@ +"""Novig order entry: NOVIG-V3 signing against the venue's thirty published +vectors, the price grid, credentials, order bodies, REST records, and the +adapter against a scripted venue that checks every signature. Records +follow the venue's documented shapes (they need an account).""" +from __future__ import annotations + +import asyncio +import base64 +import json +import uuid +from decimal import Decimal +from pathlib import Path + +import httpx +import pytest +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec + +from synpath.errors import AuthenticationError, MarketNotFound +from synpath.trading import novig_signing as sig +from synpath.trading.credentials import CredentialsMissing, NovigCredentials, load_credentials, load_novig +from synpath.trading.errors import ( + InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, PermissionDenied, +) +from synpath.trading.novig import ( + NovigTrading, build_order, client_id, error_of, fill_of_row, order_of_row, position_of, +) +from synpath.trading.types import ( + Liquidity, OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce, +) + +from conftest import load + +VECTORS = load("novig_signing_vectors.json") +ED25519 = VECTORS["keypairs"]["ed25519-test-1"] +P256 = VECTORS["keypairs"]["ecdsa-p256-test-1"] +KEY_ID = "3b6f0c2e-1d2a-4f5e-9a8b-7c6d5e4f3a21" +MANAGEMENT_ID = "8f14e45f-ceea-467f-a0e6-0b2d4c1f9e10" + +MARKET = load("novig_market.json") +MARKET_ID = MARKET["marketId"] +YES, NO = sorted(o["outcomeId"] for o in MARKET["outcomes"]) + +ORDER_ROW = { + "orderId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90", + "marketId": MARKET_ID, "outcomeId": NO, "price": "0.375", "qty": 11000, "remaining": 7000, "tif": "GTC", + "status": "OPEN", "createdTs": 1792700000000, +} +FILL_ROW = { + "fillId": "1e6a2f94-0c8b-4e1d-9f6a-2b7c3d4e5f60", "orderId": ORDER_ROW["orderId"], "marketId": MARKET_ID, + "outcomeId": YES, "qty": 4000, "cost": "25.00000", "fee": "0.56250", "taker": True, "ts": 1792700001000, +} + + +def credentials(**changes) -> NovigCredentials: + base = {"key_id": KEY_ID, "private_key_pem": ED25519["private_key_pkcs8_pem"].encode(), "env": "paper"} + return NovigCredentials(**{**base, **changes}) + + +def verified(request: httpx.Request, public_pem: str = ED25519["public_key_spki_pem"]) -> bool: + """Whether a request carries a valid NOVIG-V3 signature over what it sends.""" + path, _, query = request.url.raw_path.decode().partition("?") + stamp = int(request.headers["Novig-Timestamp"]) + message = sig.string_to_sign(stamp, request.method, path, query, request.content).encode() + key = serialization.load_pem_public_key(public_pem.encode()) + signature = base64.b64decode(request.headers["Novig-Signature"]) + try: + if isinstance(key, ec.EllipticCurvePublicKey): + key.verify(signature, message, ec.ECDSA(hashes.SHA256())) + else: + key.verify(signature, message) + except Exception: + return False + return True + + +class TestSigning: + @pytest.mark.parametrize("vector", VECTORS["vectors"], ids=lambda v: v["id"]) + def test_the_canonical_string_of_every_vector(self, vector): + given = vector["input"] + assert sig.string_to_sign(given["timestamp"], given["method"], given["path"], given["query"], + given["body"].encode()) == vector["string_to_sign"] + + @pytest.mark.parametrize("vector", [v for v in VECTORS["vectors"] if v["algorithm"] == "ed25519"], + ids=lambda v: v["id"]) + def test_ed25519_signatures_byte_for_byte(self, vector): + signer = sig.NovigSigner(KEY_ID, ED25519["private_key_pkcs8_pem"].encode()) + assert signer.algorithm == "Ed25519" + assert signer.sign(vector["string_to_sign"]) == vector["signature"] + + @pytest.mark.parametrize("vector", [v for v in VECTORS["vectors"] if v["algorithm"] != "ed25519"], + ids=lambda v: v["id"]) + def test_p256_signatures_are_der_and_verify(self, vector): + signer = sig.NovigSigner(KEY_ID, P256["private_key_pkcs8_pem"].encode()) + public = serialization.load_pem_public_key(P256["public_key_spki_pem"].encode()) + mine = base64.b64decode(signer.sign(vector["string_to_sign"])) + assert signer.algorithm == "P-256" and mine[0] == 0x30 and 68 <= len(mine) <= 72 + public.verify(mine, vector["string_to_sign"].encode(), ec.ECDSA(hashes.SHA256())) + public.verify(base64.b64decode(vector["signature"]), vector["string_to_sign"].encode(), + ec.ECDSA(hashes.SHA256())) + + def test_the_empty_body_hash(self): + assert sig.string_to_sign(1, "get", "/v3/keys").endswith("\n\n" + sig.EMPTY_SHA256) + assert sig.EMPTY_SHA256.startswith("e3b0c442") + + def test_fingerprints(self): + for pair in (ED25519, P256): + assert "sha256:" + sig.fingerprint(pair["public_key_spki_pem"]) == pair["public_key_fingerprint"] + + @pytest.mark.parametrize("algorithm", ["Ed25519", "P-256"]) + def test_a_fresh_keypair_signs(self, algorithm): + private, public = sig.generate_key(algorithm) + signer = sig.NovigSigner(KEY_ID, private) + assert signer.algorithm == algorithm and signer.public_key_pem() == public + request = httpx.Request("GET", "https://api.paper.novig.com/v3/time", + headers=signer.headers("GET", "/v3/time")) + assert verified(request, public) + + def test_other_keys_are_refused(self): + from cryptography.hazmat.primitives.asymmetric import rsa + + pem = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + with pytest.raises(ValueError, match="Ed25519 or P-256"): + sig.NovigSigner(KEY_ID, pem) + + def test_the_auth_signs_what_is_sent(self): + seen: list[httpx.Request] = [] + + def venue(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json={}) + + signer = sig.NovigSigner(KEY_ID, ED25519["private_key_pkcs8_pem"].encode()) + with httpx.Client(transport=httpx.MockTransport(venue), auth=sig.NovigAuth(signer)) as client: + client.post("https://api.paper.novig.com/v3/orders/batch", params={"q": "New York", "a": "1"}, + json={"orders": [{"price": "0.665"}]}) + client.get("https://api.paper.novig.com/v3/keys/") + assert all(verified(r) for r in seen) + assert seen[0].headers["Novig-Key-Id"] == KEY_ID and seen[0].headers["content-type"] == "application/json" + + +class TestGrid: + @pytest.mark.parametrize("price,ok", [ + ("0.001", True), ("0.050", True), ("0.051", False), ("0.055", True), ("0.665", True), ("0.667", False), + ("0.945", True), ("0.946", False), ("0.950", True), ("0.999", True), ("0.0005", False), ("1", False), + ]) + def test_on_grid(self, price, ok): + assert sig.on_grid(Decimal(price)) is ok + + @pytest.mark.parametrize("price,snapped", [ + ("0.667", "0.665"), ("0.6649", "0.660"), ("0.0505", "0.050"), ("0.054", "0.050"), ("0.9505", "0.950"), + ("0.9999", "0.999"), ("0.5", "0.500"), + ]) + def test_snap_down(self, price, snapped): + assert sig.snap_down(Decimal(price)) == Decimal(snapped) and sig.on_grid(Decimal(snapped)) + + def test_every_complement_is_on_the_grid(self): + grid = [Decimal(m) / 1000 for m in range(1, 1000) if sig.on_grid(Decimal(m) / 1000)] + assert len(grid) == 279 and all(sig.on_grid(1 - p) for p in grid) + + def test_contracts_are_cents(self): + assert sig.qty_of(Decimal("1.1")) == 110 and sig.contracts_of(110) == Decimal("1.1") + for bad in ("0.005", "0", "1.234"): + with pytest.raises(InvalidOrder): + sig.qty_of(Decimal(bad)) + + +class TestCredentials: + def write(self, tmp_path: Path, pem: str, name: str = "desk-1.pem") -> str: + path = tmp_path / name + path.write_text(pem) + return str(path) + + def test_a_trading_key(self, tmp_path): + path = self.write(tmp_path, ED25519["private_key_pkcs8_pem"]) + creds = load_novig({"NOVIG_KEY_ID": KEY_ID, "NOVIG_PRIVATE_KEY_PATH": path, "NOVIG_ENV": "paper"}) + assert creds.key_id == KEY_ID and creds.env == "paper" and creds.management_key_id is None + assert "PRIVATE" not in repr(creds) and creds.secrets + + def test_production_is_the_default(self, tmp_path): + path = self.write(tmp_path, P256["private_key_pkcs8_pem"]) + assert load_novig({"NOVIG_KEY_ID": KEY_ID, "NOVIG_PRIVATE_KEY_PATH": path}).env == "production" + + def test_a_management_key_alone(self, tmp_path): + path = self.write(tmp_path, ED25519["private_key_pkcs8_pem"], "novig-api-key-mgmt-1.pem") + creds = load_novig({"NOVIG_MANAGEMENT_KEY_ID": MANAGEMENT_ID, "NOVIG_MANAGEMENT_PRIVATE_KEY_PATH": path}) + assert creds.key_id is None and creds.management_key_id == MANAGEMENT_ID + + def test_nothing_set_is_not_configured(self): + assert load_novig({}) is None + assert load_credentials({}, redact_logs=False, dotenv="/nonexistent")["novig"] is None + + @pytest.mark.parametrize("env,match", [ + ({"NOVIG_KEY_ID": KEY_ID}, "NOVIG_PRIVATE_KEY_PATH"), + ({"NOVIG_MANAGEMENT_KEY_ID": MANAGEMENT_ID}, "NOVIG_MANAGEMENT_PRIVATE_KEY_PATH"), + ]) + def test_a_key_id_needs_its_file(self, env, match): + with pytest.raises(CredentialsMissing, match=match): + load_novig(env) + + def test_a_bad_environment(self, tmp_path): + path = self.write(tmp_path, ED25519["private_key_pkcs8_pem"]) + with pytest.raises(CredentialsMissing, match="production or paper"): + load_novig({"NOVIG_KEY_ID": KEY_ID, "NOVIG_PRIVATE_KEY_PATH": path, "NOVIG_ENV": "staging"}) + + def test_a_file_that_is_not_a_novig_key(self, tmp_path): + from cryptography.hazmat.primitives.asymmetric import rsa + + pem = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode() + path = self.write(tmp_path, pem) + with pytest.raises(CredentialsMissing, match="Ed25519 or P-256"): + load_novig({"NOVIG_KEY_ID": KEY_ID, "NOVIG_PRIVATE_KEY_PATH": path}) + + +def request(**changes) -> OrderRequest: + base = {"market_id": f"novig:{MARKET_ID}", "side": Side.BUY, "quantity": Decimal("10"), "price": Decimal("0.6")} + return OrderRequest(**{**base, **changes}) + + +def body(req: OrderRequest, **kwargs): + return build_order(req, yes_outcome=YES, no_outcome=NO, **kwargs) + + +class TestOrders: + def test_a_buy_buys_yes_in_cents(self): + assert body(request()) == {"outcomeId": YES, "price": "0.600", "qty": 1000, "tif": "GTC"} + + def test_a_sell_buys_no_at_one_minus_the_price(self): + assert body(request(side=Side.SELL)) == {"outcomeId": NO, "price": "0.400", "qty": 1000, "tif": "GTC"} + + def test_prices_snap_to_the_cheaper_side(self): + assert body(request(price=Decimal("0.6013")))["price"] == "0.600" # pays no more than 0.6013 + assert body(request(side=Side.SELL, price=Decimal("0.6013")))["price"] == "0.395" # NO for <= 0.3987 + + def test_time_in_force(self): + assert body(request(type=OrderType.MARKET))["tif"] == "IOC" + assert body(request(time_in_force=TimeInForce.IOC))["tif"] == "IOC" + assert body(request(time_in_force=TimeInForce.FOK))["tif"] == "FOK" + assert body(request(post_only=True))["tif"] == "PO" + gtd = body(request(time_in_force=TimeInForce.GTD, expires_at=1_800_000_060_000), now_ms=1_800_000_000_000) + assert (gtd["tif"], gtd["ttl"]) == ("GTT", 60_000) + po = body(request(post_only=True, time_in_force=TimeInForce.GTD, expires_at=1_800_000_060_000), + now_ms=1_800_000_000_000) + assert (po["tif"], po["ttl"]) == ("PO", 60_000) + + def test_client_ids_are_uuids(self): + same = str(uuid.uuid4()) + assert body(request(client_order_id=same))["clientId"] == same + derived = body(request(client_order_id="strategy-7/order-1"))["clientId"] + assert uuid.UUID(derived) and derived == client_id("strategy-7/order-1") + + @pytest.mark.parametrize("changes,match", [ + ({"reduce_only": True}, "reduce-only"), + ({"time_in_force": TimeInForce.DAY}, "day orders"), + ({"post_only": True, "time_in_force": TimeInForce.IOC}, "post-only"), + ({"quantity": Decimal("0.005")}, "1-cent"), + ({"type": OrderType.STOP_MARKET, "stop_price": Decimal("0.5")}, "engine"), + ({"time_in_force": TimeInForce.GTD, "expires_at": 1}, "past"), + ]) + def test_refused_before_sending(self, changes, match): + with pytest.raises(InvalidOrder, match=match): + body(request(**changes)) + + +class TestRecords: + def test_an_order_on_no_is_a_sell_at_the_yes_price(self): + order = order_of_row(ORDER_ROW, yes_outcome=YES) + assert order.side == Side.SELL and order.price == Decimal("0.625") + assert (order.quantity, order.filled, order.remaining) == (Decimal("110"), Decimal("40"), Decimal("70")) + assert order.status == OrderStatus.OPEN and order.market_id == f"novig:{MARKET_ID}" + assert order.client_order_id == ORDER_ROW["clientId"] and order.created_at == ORDER_ROW["createdTs"] + + @pytest.mark.parametrize("native,status", [ + ("PENDING", OrderStatus.PENDING), ("FILLED", OrderStatus.CLOSED), ("CANCELED", OrderStatus.CANCELED), + ("REJECTED", OrderStatus.REJECTED), + ]) + def test_order_states(self, native, status): + order = order_of_row({**ORDER_ROW, "status": native}, yes_outcome=YES) + assert order.status == status + if status == OrderStatus.CLOSED: + assert order.remaining == 0 and order.filled == order.quantity + + def test_post_only_and_good_til_time(self): + assert order_of_row({**ORDER_ROW, "tif": "PO"}, yes_outcome=YES).post_only + gtt = order_of_row({**ORDER_ROW, "tif": "GTT", "expiresTs": 1792800000000}, yes_outcome=YES) + assert gtt.time_in_force == TimeInForce.GTD and gtt.expires_at == 1792800000000 + + def test_a_resting_order_from_the_snapshot(self): + row = {k: v for k, v in ORDER_ROW.items() if k not in ("remaining", "status", "createdTs")} + order = order_of_row(row, yes_outcome=YES) + assert order.status == OrderStatus.OPEN and order.remaining == order.quantity == Decimal("110") + + def test_a_fill_from_rest_prices_from_its_cost(self): + fill = fill_of_row(FILL_ROW, yes_outcome=YES) + assert fill.side == Side.BUY and fill.price == Decimal("0.625") and fill.quantity == Decimal("40") + assert fill.fee == Decimal("0.56250") and fill.liquidity == Liquidity.TAKER and fill.fee_currency == "USD" + + def test_an_uncharged_fill_paid_nothing(self): + fill = fill_of_row({k: v for k, v in FILL_ROW.items() if k != "fee"} | {"taker": False}, yes_outcome=YES) + assert fill.fee == 0 and fill.liquidity == Liquidity.MAKER + + def test_a_fill_from_the_stream_carries_its_price_and_no_fee(self): + event = {"kind": "fill", "fillId": "f-1", "orderId": "o-1", "outcomeId": NO, "price": "0.370", "qty": 40, + "remaining": 70, "taker": True} + fill = fill_of_row(event, yes_outcome=YES, market_id=MARKET_ID) + assert fill.side == Side.SELL and fill.price == Decimal("0.630") and fill.fee is None + assert fill.market_id == f"novig:{MARKET_ID}" and fill.quantity == Decimal("0.4") + + def test_a_position_nets_both_outcomes(self): + rows = [{"marketId": MARKET_ID, "outcomeId": YES, "qty": 11000, "cost": "68.75000"}] + long = position_of(MARKET_ID, rows, yes_outcome=YES) + assert long.side == PositionSide.LONG and long.contracts == Decimal("110") + assert long.entry_price == Decimal("0.625") and long.inventory_no == 0 + rows.append({"marketId": MARKET_ID, "outcomeId": NO, "qty": 20000, "cost": "75.00000"}) + short = position_of(MARKET_ID, rows, yes_outcome=YES) + assert short.side == PositionSide.SHORT and short.contracts == Decimal("90") and short.entry_price is None + assert (short.inventory_yes, short.inventory_no, short.margin) == (Decimal("110"), Decimal("200"), Decimal("143.75")) + + def test_a_no_position_has_a_yes_entry_price(self): + rows = [{"marketId": MARKET_ID, "outcomeId": NO, "qty": 10000, "cost": "37.50000"}] + assert position_of(MARKET_ID, rows, yes_outcome=YES).entry_price == Decimal("0.625") + + +class TestErrors: + @staticmethod + def refused(status: int, code: str): + from synpath.errors import ExchangeError + + return error_of(ExchangeError("novig: refused", body={"code": code, "message": code.lower()}, status=status)) + + @pytest.mark.parametrize("status,code,kind", [ + (422, "INSUFFICIENT_BALANCE", InsufficientFunds), (400, "INVALID_PRICE", InvalidOrder), + (409, "MARKET_CLOSED", MarketHalted), (404, "ORDER_NOT_FOUND", OrderNotFound), + (404, "OUTCOME_NOT_FOUND", MarketNotFound), (403, "KYC_REQUIRED", PermissionDenied), + (403, "SCOPE_INSUFFICIENT", PermissionDenied), (423, "SELF_EXCLUDED", PermissionDenied), + (400, "BATCH_REJECTED", OrderRejected), (401, "SIGNATURE_REJECTED", AuthenticationError), + ]) + def test_codes(self, status, code, kind): + assert isinstance(self.refused(status, code), kind) + + def test_geolocation_says_why(self): + error = self.refused(451, "GEOLOCATION_OUT_OF_BOUNDARY") + assert isinstance(error, PermissionDenied) and "Nevada" in str(error) + + +class Venue: + """Answers signed requests by `METHOD /path`, checks each signature, and records them.""" + + def __init__(self, **answers): + self.answers = answers + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + if not verified(req): + return httpx.Response(401, json={"code": "SIGNATURE_REJECTED", "message": "signature verification failed"}) + answer = self.answers.get(f"{req.method} {req.url.path}") + if answer is None: + return httpx.Response(404, json={"code": "NOT_FOUND", "message": "not found"}) + if callable(answer): + answer = answer(req) + return answer if isinstance(answer, httpx.Response) else httpx.Response(200, json=answer) + + def sent(self, method: str, path: str) -> list[httpx.Request]: + return [r for r in self.requests if r.method == method and r.url.path == path] + + +class Catalog: + def __init__(self): + self.asked: list[str] = [] + + def outcome_ids(self, market_id): + self.asked.append(market_id) + if market_id != MARKET_ID: + raise MarketNotFound(f"novig: no open market {market_id}") + return YES, NO + + def fetch_fee_schedule(self, market_id): + from synpath.novig import fee_schedule_of + + return fee_schedule_of(MARKET) + + def close(self): + pass + + +def adapter(venue: Venue, **creds) -> NovigTrading: + transport = httpx.MockTransport(venue) + return NovigTrading(credentials(**creds), catalog=Catalog(), client=httpx.AsyncClient(transport=transport), + management_client=httpx.AsyncClient(transport=transport)) + + +def run(coroutine): + return asyncio.run(coroutine) + + +class TestAdapter: + def test_paper_and_production_hosts(self): + assert NovigTrading(credentials(), catalog=Catalog()).url == "https://api.paper.novig.com" + assert NovigTrading(credentials(env="production"), catalog=Catalog()).url == "https://api.novig.com" + + def test_create_order_sends_a_signed_placement(self): + venue = Venue(**{"POST /v3/orders": httpx.Response(201, json={"orderId": "o-1"})}) + order = run(adapter(venue).create_order(request(side=Side.SELL, client_order_id="c-1"))) + sent = json.loads(venue.sent("POST", "/v3/orders")[0].content) + assert sent == {"outcomeId": NO, "price": "0.400", "qty": 1000, "tif": "GTC", "clientId": client_id("c-1")} + assert order.id == "o-1" and order.status == OrderStatus.PENDING and order.side == Side.SELL + assert order.price == Decimal("0.6") and order.quantity == Decimal("10") and order.client_order_id == "c-1" + + def test_an_unknown_market_is_refused_before_sending(self): + venue = Venue() + with pytest.raises(MarketNotFound): + run(adapter(venue).create_order(request(market_id="novig:00000000-0000-0000-0000-000000000000"))) + assert venue.requests == [] + + def test_an_unknown_outcome_is_not_an_unknown_order(self): + venue = Venue(**{"POST /v3/orders": httpx.Response( + 404, json={"code": "OUTCOME_NOT_FOUND", "message": "Outcome not found"})}) + with pytest.raises(MarketNotFound) as raised: + run(adapter(venue).create_order(request())) + assert not isinstance(raised.value, OrderNotFound) + + def test_the_venues_refusal_is_typed(self): + venue = Venue(**{"POST /v3/orders": httpx.Response( + 422, json={"code": "INSUFFICIENT_BALANCE", "message": "Insufficient balance"})}) + with pytest.raises(InsufficientFunds): + run(adapter(venue).create_order(request())) + + def test_a_batch_places_in_one_request(self): + venue = Venue(**{"POST /v3/orders/batch": httpx.Response(201, json=[{"orderId": "a"}, {"orderId": "b"}])}) + results = run(adapter(venue).create_orders([request(), request(quantity=Decimal("0.001")), request(side=Side.SELL)])) + assert [r.id if not isinstance(r, Exception) else type(r) for r in results] == ["a", InvalidOrder, "b"] + assert len(json.loads(venue.sent("POST", "/v3/orders/batch")[0].content)["orders"]) == 2 + + def test_a_refused_batch_places_nothing(self): + refusal = {"code": "BATCH_REJECTED", "message": "1 order(s) refused.", + "rejected": [{"index": 1, "outcomeId": NO, "reason": "Invalid price"}]} + venue = Venue(**{"POST /v3/orders/batch": httpx.Response(400, json=refusal)}) + first, second = run(adapter(venue).create_orders([request(), request(side=Side.SELL)])) + assert isinstance(first, OrderRejected) and "as a whole" in str(first) + assert isinstance(second, InvalidOrder) and "Invalid price" in str(second) + + def test_cancel_reads_the_order_back(self): + venue = Venue(**{ + f"DELETE /v3/orders/{ORDER_ROW['orderId']}": {"orderId": ORDER_ROW["orderId"], "status": "OPEN"}, + f"GET /v3/orders/{ORDER_ROW['orderId']}": ORDER_ROW, + }) + order = run(adapter(venue).cancel_order(ORDER_ROW["orderId"])) + assert order.status == OrderStatus.PENDING_CANCEL and order.side == Side.SELL + + def test_an_unknown_order(self): + venue = Venue(**{"GET /v3/orders/nope": httpx.Response(404, json={"code": "ORDER_NOT_FOUND", "message": "x"})}) + with pytest.raises(OrderNotFound): + run(adapter(venue).fetch_order("nope")) + + def test_batch_cancel_is_partial(self): + canceled = {**ORDER_ROW, "status": "CANCELED"} + venue = Venue(**{ + "DELETE /v3/orders/batch": httpx.Response(207, json={ + "canceled": [ORDER_ROW["orderId"]], "notCanceled": [{"orderId": "gone", "reason": "NOT_FOUND"}]}), + f"GET /v3/orders/{ORDER_ROW['orderId']}": canceled, + }) + done, missing = run(adapter(venue).cancel_orders([ORDER_ROW["orderId"], "gone"])) + assert done.status == OrderStatus.CANCELED and isinstance(missing, OrderNotFound) + assert json.loads(venue.sent("DELETE", "/v3/orders/batch")[0].content) == {"orderIds": [ORDER_ROW["orderId"], "gone"]} + + def test_a_batch_cancel_of_only_unknown_ids(self): + venue = Venue(**{"DELETE /v3/orders/batch": httpx.Response(404, json={ + "canceled": [], "notCanceled": [{"orderId": "gone", "reason": "NOT_FOUND"}]})}) + (missing,) = run(adapter(venue).cancel_orders(["gone"])) + assert isinstance(missing, OrderNotFound) + + def test_cancel_all_on_a_market(self): + venue = Venue(**{"DELETE /v3/orders": {"canceled": 3}}) + assert run(adapter(venue).cancel_all_orders(market_id=f"novig:{MARKET_ID}")) == 3 + assert venue.sent("DELETE", "/v3/orders")[0].url.params["market"] == MARKET_ID + + def test_open_orders_page_by_page(self): + pages = {None: {"items": [ORDER_ROW], "next": "p2"}, "p2": {"items": [{**ORDER_ROW, "orderId": "o-2"}]}} + venue = Venue(**{"GET /v3/orders": lambda req: pages[req.url.params.get("after")]}) + orders = run(adapter(venue).fetch_open_orders(market_id=f"novig:{MARKET_ID}")) + assert [o.id for o in orders] == [ORDER_ROW["orderId"], "o-2"] + assert all(r.url.params["status"] == "OPEN" and r.url.params["market"] == MARKET_ID for r in venue.requests) + + def test_orders_by_state(self): + venue = Venue(**{"GET /v3/orders": {"items": [{**ORDER_ROW, "status": "FILLED"}], "next": "n"}}) + page = run(adapter(venue).fetch_orders(status="closed", limit=10)) + assert page[0].status == OrderStatus.CLOSED and page.next_cursor == "n" + assert venue.requests[0].url.params["status"] == "FILLED" and venue.requests[0].url.params["limit"] == "10" + + def test_fills(self): + venue = Venue(**{"GET /v3/portfolio/fills": {"items": [FILL_ROW], "next": None}}) + page = run(adapter(venue).fetch_my_trades(since=1792700000000, limit=5)) + assert page[0].id == FILL_ROW["fillId"] and page[0].fee == Decimal("0.56250") + assert venue.requests[0].url.params["startsAfter"] == "1792699999999" + + def test_positions(self): + venue = Venue(**{"GET /v3/portfolio/positions": [ + {"marketId": MARKET_ID, "outcomeId": YES, "qty": 11000, "cost": "68.75000"}]}) + (position,) = run(adapter(venue).fetch_positions()) + assert position.side == PositionSide.LONG and position.contracts == Decimal("110") + + def test_balance_is_the_subaccounts(self): + venue = Venue(**{f"GET /v3/account/subaccounts/{KEY_ID}/balance": {"keyId": KEY_ID, "balance": "1234.50000"}}) + balance = run(adapter(venue).fetch_balance()) + assert balance.total == balance.available == Decimal("1234.50000") and balance.locked is None + assert balance.currency == "USD" and balance.account.subaccount == KEY_ID + + def test_fee_estimate(self): + estimate = run(adapter(Venue()).fetch_fee_estimate(f"novig:{MARKET_ID}", Side.BUY, Decimal("0.5"), Decimal("100"))) + assert estimate.taker_fee == Decimal("1.5") and estimate.maker_fee == Decimal("-0.375") + + def test_a_location_refusal(self): + venue = Venue(**{"POST /v3/orders": httpx.Response(451, json={ + "code": "GEOLOCATION_OUT_OF_BOUNDARY", "message": "outside the boundary"})}) + with pytest.raises(PermissionDenied, match="geolocation"): + run(adapter(venue).create_order(request())) + + def test_subaccounts_need_the_management_key(self): + with pytest.raises(CredentialsMissing, match="NOVIG_MANAGEMENT_KEY_ID"): + run(adapter(Venue()).subaccounts()) + + def test_trading_needs_the_trading_key(self): + creds = {"key_id": None, "private_key_pem": None, "management_key_id": MANAGEMENT_ID, + "management_private_key_pem": ED25519["private_key_pkcs8_pem"].encode()} + with pytest.raises(CredentialsMissing, match="NOVIG_KEY_ID"): + run(adapter(Venue(), **creds).fetch_balance()) + + def test_funding_a_subaccount(self): + creds = {"management_key_id": MANAGEMENT_ID, "management_private_key_pem": ED25519["private_key_pkcs8_pem"].encode()} + venue = Venue(**{f"POST /v3/account/subaccounts/{KEY_ID}/transfer": httpx.Response(202, json={ + "transferId": "t-1", "status": "Requested"})}) + answer = run(adapter(venue, **creds).fund_subaccount(KEY_ID, Decimal("250"), client_transfer_id="desk-1-a")) + sent = venue.sent("POST", f"/v3/account/subaccounts/{KEY_ID}/transfer")[0] + assert json.loads(sent.content) == {"direction": "fund", "amount": "250.00000", "clientTransferId": "desk-1-a"} + assert sent.headers["Novig-Key-Id"] == MANAGEMENT_ID and answer["status"] == "Requested" + with pytest.raises(Exception, match="five decimal"): + run(adapter(venue, **creds).fund_subaccount(KEY_ID, Decimal("1.000001"))) + + def test_opening_a_subaccount_sends_only_the_public_key(self): + creds = {"management_key_id": MANAGEMENT_ID, "management_private_key_pem": ED25519["private_key_pkcs8_pem"].encode()} + venue = Venue(**{"POST /v3/account/subaccounts": httpx.Response(201, json={ + "keyId": "k-2", "label": "desk-2", "balance": "0.00000"})}) + private, public = sig.generate_key() + answer = run(adapter(venue, **creds).open_subaccount("desk-2", public)) + sent = json.loads(venue.sent("POST", "/v3/account/subaccounts")[0].content) + assert sent == {"label": "desk-2", "publicKey": public, "algorithm": "Ed25519"} and answer["keyId"] == "k-2" + assert private.decode() not in json.dumps(sent) + + def test_it_is_the_clients_novig_adapter(self): + import synpath + + client = synpath.Client({"novig": credentials()}) + assert isinstance(client.trading("novig"), NovigTrading) + assert NovigTrading.has["create_orders"] and not NovigTrading.has["rfq"] + + +class TestSetup: + def test_init_writes_a_trading_key(self, tmp_path): + from synpath.trading.init import Prompter, run + from synpath.trading.credentials import read_dotenv + + pem = tmp_path / "desk-1.pem" + pem.write_text(ED25519["private_key_pkcs8_pem"]) + answers = iter(["n"] * 10 + ["y", "paper", KEY_ID, str(pem), ""]) + p = Prompter(ask=lambda _q: next(answers), ask_secret=lambda _q: "", say=lambda _: None) + env = tmp_path / ".env" + assert run(env, p) == 0 + assert read_dotenv(env) == {"NOVIG_ENV": "paper", "NOVIG_KEY_ID": KEY_ID, "NOVIG_PRIVATE_KEY_PATH": str(pem.resolve())} + loaded = load_credentials({}, dotenv=env, redact_logs=False)["novig"] + assert loaded is not None and loaded.key_id == KEY_ID and loaded.env == "paper" + + def test_doctor_names_the_keys_and_prints_no_secret(self, tmp_path, monkeypatch, capsys): + from synpath.trading.__main__ import doctor + + pem = tmp_path / "desk-1.pem" + pem.write_text(ED25519["private_key_pkcs8_pem"]) + env = tmp_path / ".env" + env.write_text(f"NOVIG_KEY_ID={KEY_ID}\nNOVIG_PRIVATE_KEY_PATH={pem}\nNOVIG_ENV=paper\n") + for name in ("NOVIG_KEY_ID", "NOVIG_PRIVATE_KEY_PATH", "NOVIG_ENV", "NOVIG_MANAGEMENT_KEY_ID"): + monkeypatch.delenv(name, raising=False) + assert doctor(str(env)) == 0 + line = next(row for row in capsys.readouterr().out.splitlines() if row.startswith("novig")) + assert "configured (paper)" in line and f"trading key {KEY_ID}" in line + assert "MC4CAQAw" not in line + + def test_the_engine_streams_a_keyed_venue(self): + from synpath.engine.feeds import default_streams + from synpath.ws.novig import NovigMarketStream, NovigUserStream + + trading = NovigTrading(credentials(), catalog=Catalog()) + streams = default_streams({"novig": trading}, {"novig": credentials()})["novig"] + assert isinstance(streams.market, NovigMarketStream) and not streams.market.polling + assert isinstance(streams.private, NovigUserStream) and streams.private.trading is trading + managed = credentials(key_id=None, private_key_pem=None, management_key_id=MANAGEMENT_ID, + management_private_key_pem=ED25519["private_key_pkcs8_pem"].encode()) + streams = default_streams({"novig": trading}, {"novig": managed})["novig"] + assert streams.market.polling and streams.private is None diff --git a/tests/test_novig_ws.py b/tests/test_novig_ws.py new file mode 100644 index 0000000..c7ed5eb --- /dev/null +++ b/tests/test_novig_ws.py @@ -0,0 +1,477 @@ +"""Novig's streams: the public book polled without a key, the websocket's +verbs and channels with one, and the account's private channels. Polled +answers come from the recorded samples through an `httpx.MockTransport`; +websocket frames follow the venue's documented shapes, against a fake socket.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +import httpx +import pytest + +from synpath import AuthenticationError +from synpath.trading.types import OrderStatus, PositionSide, Side +from synpath.ws.base import ( + BookEvent, FillEvent, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, StreamStatusEvent, TradeEvent, +) +from synpath.ws.novig import NovigMarketStream, NovigUserStream, lifecycle_kind, trade_event + +from conftest import load +from test_novig_trading import ORDER_ROW, credentials, verified + +MARKET = load("novig_market.json") +BOOK = load("novig_book.json") +TRADES = load("novig_trades.json") +MID = MARKET["marketId"] +SID = f"novig:{MID}" +YES, NO = sorted(o["outcomeId"] for o in MARKET["outcomes"]) + + +class Catalog: + def outcome_ids(self, market_id): + return YES, NO + + +class FakeSocket: + def __init__(self): + self.sent: list = [] + + async def send(self, text): + self.sent.append(json.loads(text)) + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +async def settle(): + for _ in range(5): + await asyncio.sleep(0) + await asyncio.sleep(0.01) + for _ in range(5): + await asyncio.sleep(0) + + +def of(events, kind): + return [e for e in events if isinstance(e, kind)] + + +class TestRecords: + def test_a_print_on_yes_is_a_taker_selling_it(self): + row = next(r for r in TRADES["items"] if r["outcomeId"] == YES) + event = trade_event(row, yes_outcome=YES, market=MID) + assert event.taker_side == Side.SELL and event.price == Decimal(row["price"]) + assert event.quantity == Decimal(row["qty"]) / 100 and event.market_id == SID + + def test_a_print_on_no_is_a_taker_buying_yes(self): + event = trade_event({**TRADES["items"][0], "outcomeId": NO, "price": "0.370"}, yes_outcome=YES, market=MID) + assert event.taker_side == Side.BUY and event.price == Decimal("0.630") + + def test_lifecycle_deltas_in_both_documented_forms(self): + assert lifecycle_kind({"kind": "GRADE", "status": "SETTLED"}) == ("GRADE", "SETTLED") + assert lifecycle_kind("GOLIVE") == ("GOLIVE", None) + + +# --------------------------------------------------------------------------- +# Polled, without a key +# --------------------------------------------------------------------------- + +class Public: + """The public catalog's book, tape and market routes.""" + + def __init__(self): + self.book = BOOK + self.trades = TRADES + self.market = MARKET + self.requests: list[httpx.Request] = [] + self.throttle = False + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if self.throttle: + return httpx.Response(429, json={"code": "RATE_LIMIT_EXCEEDED", "message": "slow down"}) + path = request.url.path + if path.endswith("/book"): + return httpx.Response(200, json=self.book) + if path.endswith("/trades"): + return httpx.Response(200, json=self.trades) + return httpx.Response(200, json=self.market) + + +def polled(api: Public) -> NovigMarketStream: + client = httpx.AsyncClient(transport=httpx.MockTransport(api)) + return NovigMarketStream(catalog=Catalog(), client=client, limiter=None, interval=0.01) + + +class TestPolled: + def test_without_a_key_it_polls(self): + stream = NovigMarketStream(catalog=Catalog()) + assert stream.polling and stream.has["watch_order_book"] == "partial" + assert NovigMarketStream.has["watch_order_book"] is True + + def test_a_book_then_its_changes(self): + async def go(): + api = Public() + stream = polled(api) + await stream.watch_order_book([SID]) + await stream._open() + first = stream.handle(await stream.poll()) + top = BOOK["orders"][YES][0] + api.book = {**BOOK, "orders": {**BOOK["orders"], YES: [{**top, "qty": top["qty"] + 100}] + BOOK["orders"][YES][1:]}} + second = stream.handle(await stream.poll()) + unchanged = stream.handle(await stream.poll()) + await stream.close() + return first, second, unchanged, api + + first, second, unchanged, api = asyncio.run(go()) + (snapshot,) = of(first, BookEvent) + assert snapshot.kind == "snapshot" and snapshot.best_bid == Decimal(BOOK["orders"][YES][0]["price"]) + assert snapshot.best_ask == 1 - Decimal(BOOK["orders"][NO][0]["price"]) + (delta,) = of(second, BookEvent) + assert delta.kind == "delta" and [level.price for level in delta.bids] == [snapshot.best_bid] + assert delta.bids[0].size == snapshot.bids[0].size + 1 + assert of(unchanged, BookEvent) == [] + assert api.requests[0].url.path == f"/v3/public/catalog/markets/{MID}/book" + + def test_new_prints_only(self): + async def go(): + api = Public() + stream = polled(api) + await stream.watch_trades([SID]) + await stream._open() + seeded = stream.handle(await stream.poll()) + new = {**TRADES["items"][0], "tradeId": "new-trade", "ts": TRADES["items"][0]["ts"] + 1} + api.trades = {"items": [new, *TRADES["items"]]} + later = stream.handle(await stream.poll()) + await stream.close() + return seeded, later + + seeded, later = asyncio.run(go()) + assert of(seeded, TradeEvent) == [] + assert [e.id for e in of(later, TradeEvent)] == ["new-trade"] + + def test_status_changes(self): + async def go(): + api = Public() + stream = polled(api) + stream.status_every = 1 + await stream.watch_market_status([SID]) + await stream._open() + stream.handle(await stream.poll()) + api.market = {**MARKET, "status": "CLOSED"} + events = stream.handle(await stream.poll()) + await stream.close() + return events + + (event,) = of(asyncio.run(go()), MarketStatusEvent) + assert (event.market_id, event.state, event.native) == (SID, "closed", "CLOSED") + + def test_a_throttled_read_keeps_the_last_answer(self): + async def go(): + api = Public() + stream = polled(api) + await stream.watch_order_book([SID]) + await stream._open() + stream.handle(await stream.poll()) + api.throttle = True + message = await stream.poll() + events = stream.handle(message) + await stream.close() + return message, events, stream + + message, events, stream = asyncio.run(go()) + assert message["throttled"] and events == [] and stream.book(SID).ready + + +# --------------------------------------------------------------------------- +# The websocket, with a key +# --------------------------------------------------------------------------- + +def keyed(**kwargs) -> NovigMarketStream: + stream = NovigMarketStream(credentials=credentials(), catalog=Catalog(), **kwargs) + stream._ws = FakeSocket() + return stream + + +def snapshot_message(book: dict | None = None, *, nonce: int = 1, lifecycle: str = "OPEN", form: str = "spec") -> str: + orders = book or BOOK["orders"] + if form == "docs": + orders = {k: [{"order": o["orderId"], "price": o["price"], "qty": o["qty"]} for o in v] for k, v in orders.items()} + return json.dumps({"ts": 1, "nonce": nonce, "subscribed": {"markets": {MID: "book"}, "events": {}, "private": []}, + "snapshot": {MID: {"eventId": MARKET["eventId"], "book": {"seq": 100, "orders": orders}, + "lifecycle": {"seq": 3, "status": lifecycle}}}}) + + +def delta(channel: str, seq: int, deltas: list) -> str: + return json.dumps({"ts": 2, "delta": {MID: {"eventId": MARKET["eventId"], channel: {"seq": seq, "deltas": deltas}}}}) + + +class TestWebsocket: + def test_the_upgrade_is_signed(self): + stream = keyed() + assert not stream.polling and stream.url == "wss://api.paper.novig.com/v3/ws" + headers = stream.headers() + request = httpx.Request("GET", "https://api.paper.novig.com/v3/ws", headers=headers) + assert verified(request) and headers["Novig-Key-Id"] == credentials().key_id + + def test_one_channel_per_market(self): + async def go(): + stream = keyed() + await stream.watch_ticker([SID]) + await stream.watch_order_book([SID]) + await stream.watch_trades([SID]) + return stream._ws.sent + + sent = asyncio.run(go()) + assert [s["subscribe"]["markets"] for s in sent] == [{MID: "bbo"}, {MID: "book"}] + assert [s["nonce"] for s in sent] == [1, 2] + + def test_a_snapshot_then_per_order_changes(self): + async def go(): + stream = keyed() + await stream.watch_order_book([SID]) + stream._dispatch(snapshot_message()) + top = BOOK["orders"][YES][0] + stream._dispatch(delta("book", 101, [ + {"kind": "update", "orderId": top["orderId"], "remaining": top["qty"] - 1000}, + {"kind": "add", "orderId": "new", "outcomeId": NO, "price": "0.300", "qty": 500}, + ])) + return stream, drain(stream) + + stream, events = asyncio.run(go()) + snapshot, change = of(events, BookEvent) + assert snapshot.kind == "snapshot" and snapshot.sequence == 100 + assert snapshot.best_bid == Decimal(BOOK["orders"][YES][0]["price"]) + assert change.kind == "delta" and change.sequence == 101 + bid = {level.price: level.size for level in change.bids} + assert bid[snapshot.best_bid] == snapshot.bids[0].size - 10 + assert {level.price: level.size for level in change.asks} == {Decimal("0.700"): Decimal("5")} + assert stream.book(SID).ready + + def test_the_documentations_field_names(self): + async def go(): + stream = keyed() + await stream.watch_order_book([SID]) + stream._dispatch(snapshot_message(form="docs")) + first = BOOK["orders"][YES][0] + stream._dispatch(delta("book", 101, [{"kind": "remove", "order": first["orderId"], "reason": "cancel"}])) + return drain(stream) + + snapshot, change = of(asyncio.run(go()), BookEvent) + assert snapshot.bids and change.kind == "delta" + + def test_fills_on_the_book_are_trades_when_both_are_watched(self): + async def go(): + stream = keyed() + await stream.watch_order_book([SID]) + await stream.watch_trades([SID]) + stream._dispatch(snapshot_message()) + top = BOOK["orders"][NO][0] + stream._dispatch(delta("book", 101, [ + {"kind": "remove", "order": top["orderId"], "reason": "fill"}, + {"kind": "add", "order": top["orderId"], "outcome": NO, "price": top["price"], "qty": top["qty"] - 300}, + ])) + return drain(stream), top + + events, top = asyncio.run(go()) + (trade,) = of(events, TradeEvent) + assert trade.quantity == Decimal("3") and trade.taker_side == Side.BUY + assert trade.price == 1 - Decimal(top["price"]) and trade.info["source"] == "book" + + def test_a_gap_asks_for_a_snapshot(self): + async def go(): + stream = keyed() + await stream.watch_order_book([SID]) + stream._dispatch(snapshot_message()) + stream._dispatch(delta("book", 105, [{"kind": "add", "orderId": "x", "outcomeId": YES, "price": "0.5", "qty": 1}])) + await settle() + return stream, drain(stream) + + stream, events = asyncio.run(go()) + assert any(isinstance(e, StreamStatusEvent) and e.state == "gap" for e in events) + assert stream._ws.sent[-1]["snapshot"] == {"markets": {MID: "book"}} + assert not stream.book(SID).ready + + def test_the_tape(self): + async def go(): + stream = keyed() + await stream.watch_trades([SID]) + stream._dispatch(json.dumps({"ts": 1, "nonce": 1, "snapshot": {MID: {"eventId": "e", "trades": {"seq": 9, "trades": []}}}})) + stream._dispatch(delta("trades", 10, [TRADES["items"][0]])) + stream._dispatch(delta("trades", 10, [TRADES["items"][0]])) # a repeat is dropped + return drain(stream) + + (trade,) = of(asyncio.run(go()), TradeEvent) + assert trade.id == TRADES["items"][0]["tradeId"] + + def test_top_of_book_from_bbo(self): + async def go(): + stream = keyed() + await stream.watch_ticker([SID]) + stream._dispatch(json.dumps({"ts": 1, "nonce": 1, "snapshot": {MID: {"eventId": "e", "bbo": { + "seq": 4, "bbo": {YES: {"price": "0.625", "qty": 1000}, NO: {"price": "0.370", "qty": 500}}}}}})) + stream._dispatch(delta("bbo", 5, [{"outcomeId": NO}])) + return drain(stream) + + first, second = of(asyncio.run(go()), QuoteEvent) + assert (first.bid, first.ask, first.bid_size, first.ask_size) == (Decimal("0.625"), Decimal("0.630"), Decimal("10"), Decimal("5")) + assert second.ask is None and second.bid == Decimal("0.625") + + def test_lifecycle(self): + async def go(): + stream = keyed() + await stream.watch_market_status([SID]) + stream._dispatch(json.dumps({"ts": 1, "snapshot": {MID: {"eventId": "e", "lifecycle": {"seq": 3, "status": "OPEN"}}}})) + stream._dispatch(delta("lifecycle", 4, ["GOLIVE"])) + stream._dispatch(delta("lifecycle", 5, [{"kind": "GRADE", "status": "SETTLED"}])) + return drain(stream) + + opened, live, graded = of(asyncio.run(go()), MarketStatusEvent) + assert opened.state == "open" and live.state == "updated" and live.info["voids_resting_orders"] + assert (graded.state, graded.native) == ("settled", "GRADE") + + def test_a_refusal_is_reported(self): + async def go(): + stream = keyed() + stream._dispatch(json.dumps({"code": "SUBSCRIPTION_LIMIT_EXCEEDED", "message": "too many", "nonce": 3})) + return drain(stream) + + (event,) = asyncio.run(go()) + assert event.state == "error" and "SUBSCRIPTION_LIMIT_EXCEEDED" in event.detail + + +# --------------------------------------------------------------------------- +# The account +# --------------------------------------------------------------------------- + +class Trading: + def __init__(self): + self.asked: list[str] = [] + + async def fetch_order(self, order_id): + from synpath.trading.novig import order_of_row + + self.asked.append(order_id) + return order_of_row({**ORDER_ROW, "orderId": order_id, "status": "OPEN", "remaining": 11000}, + yes_outcome=YES) + + +def user(**kwargs) -> NovigUserStream: + stream = NovigUserStream(credentials=credentials(), catalog=Catalog(), **kwargs) + stream._ws = FakeSocket() + return stream + + +OPEN = {k: v for k, v in ORDER_ROW.items() if k not in ("remaining", "status", "createdTs")} + + +class TestAccount: + def test_it_needs_the_trading_key(self): + with pytest.raises(AuthenticationError): + NovigUserStream(credentials=credentials(key_id=None, private_key_pem=None)) + + def test_subscribing(self): + async def go(): + stream = user() + await stream.watch_orders() + await stream.watch_positions() + return stream._ws.sent + + assert [s["subscribe"]["private"] for s in asyncio.run(go())] == [["orders"], ["orders", "positions"]] + + def test_orders_from_snapshot_to_fill(self): + async def go(): + stream = user() + await stream.watch_orders() + stream._dispatch(json.dumps({"ts": 1, "nonce": 1, "subscribed": {"private": ["orders"]}, "snapshot": {}, + "orders": {"seq": 907, "open": [OPEN]}})) + stream._dispatch(json.dumps({"ts": 2, "delta": {}, "orders": {"seq": 908, "deltas": [ + {"kind": "fill", "fillId": "f-1", "orderId": OPEN["orderId"], "outcomeId": NO, "price": "0.370", + "qty": 4000, "remaining": 7000, "taker": False}]}})) + stream._dispatch(json.dumps({"ts": 3, "delta": {}, "orders": {"seq": 909, "deltas": [ + {"kind": "cancel", "orderId": OPEN["orderId"], "reason": "GO_LIVE"}]}})) + await settle() + return drain(stream) + + events = asyncio.run(go()) + orders, fills = of(events, OrderEvent), of(events, FillEvent) + assert [o.native for o in orders] == ["snapshot", "fill", "cancel:GO_LIVE"] + assert orders[0].order.side == Side.SELL and orders[0].order.remaining == Decimal("110") + assert fills[0].fill.price == Decimal("0.630") and fills[0].fill.quantity == Decimal("40") + assert (orders[1].order.filled, orders[1].order.remaining, orders[1].order.quantity) == ( + Decimal("40"), Decimal("70"), Decimal("110")) + assert orders[2].order.status == OrderStatus.CANCELED and orders[2].order.info["reason"] == "GO_LIVE" + + def test_fills_before_open_and_a_full_fill(self): + async def go(): + stream = user() + stream._dispatch(json.dumps({"ts": 1, "delta": {}, "orders": {"seq": 1, "deltas": [ + {"kind": "open", **OPEN, "qty": 7000}, + {"kind": "fill", "fillId": "f-2", "orderId": OPEN["orderId"], "outcomeId": NO, "price": "0.375", + "qty": 7000, "remaining": 0, "taker": False}]}})) + await settle() + return drain(stream) + + orders = of(asyncio.run(go()), OrderEvent) + assert orders[-1].order.status == OrderStatus.CLOSED and orders[-1].order.filled == Decimal("70") + + def test_fills_matched_on_entry_come_before_the_open(self): + async def go(): + stream = user() + stream._dispatch(json.dumps({"ts": 1, "delta": {}, "orders": {"seq": 1, "deltas": [ + {"kind": "fill", "fillId": "f-4", "orderId": OPEN["orderId"], "outcomeId": NO, "price": "0.370", + "qty": 4000, "remaining": 7000, "taker": True}, + {"kind": "open", **OPEN, "qty": 7000}]}})) + await settle() + return drain(stream) + + events = asyncio.run(go()) + assert [type(e).__name__ for e in events] == ["FillEvent", "OrderEvent", "OrderEvent"] + rested = events[-1].order + assert rested.status == OrderStatus.OPEN + assert (rested.filled, rested.remaining, rested.quantity) == (Decimal("40"), Decimal("70"), Decimal("110")) + + def test_an_order_first_seen_in_a_fill_is_read(self): + async def go(): + trading = Trading() + stream = user(trading=trading) + stream._dispatch(json.dumps({"ts": 1, "delta": {}, "orders": {"seq": 1, "deltas": [ + {"kind": "fill", "fillId": "f-3", "orderId": "placed-elsewhere", "outcomeId": NO, "price": "0.375", + "qty": 100, "remaining": 10900, "taker": True}]}})) + await settle() + return trading, drain(stream) + + trading, events = asyncio.run(go()) + assert trading.asked == ["placed-elsewhere"] and of(events, FillEvent)[0].fill.order_id == "placed-elsewhere" + + def test_a_heartbeat_ahead_is_a_gap(self): + async def go(): + stream = user() + stream._dispatch(json.dumps({"ts": 1, "snapshot": {}, "orders": {"seq": 907, "open": []}})) + await settle() + stream._dispatch(json.dumps({"heartbeat": {"orders": 911, "ts": 2}})) + await settle() + return stream, drain(stream) + + stream, events = asyncio.run(go()) + gap = [e for e in events if isinstance(e, StreamStatusEvent) and e.state == "gap"] + assert gap and gap[0].reconcile_required and stream._ws.sent[-1]["snapshot"] == {"private": ["orders"]} + + def test_positions(self): + async def go(): + stream = user() + stream._dispatch(json.dumps({"ts": 1, "snapshot": {}, "positions": {"seq": 12, "positions": [ + {"marketId": MID, "outcomeId": YES, "qty": 11000, "cost": "68.75000"}]}})) + stream._dispatch(json.dumps({"ts": 2, "delta": {}, "positions": {"seq": 13, "deltas": [ + {"marketId": MID, "outcomeId": YES, "qty": 0, "cost": "0.00000"}]}})) + await settle() + return drain(stream) + + held, closed = of(asyncio.run(go()), PositionEvent) + assert held.position.side == PositionSide.LONG and held.position.contracts == Decimal("110") + assert closed.position.side == PositionSide.FLAT and closed.position.contracts == 0 From 2d719ae3368f7e82e68347589159e00635aa628e Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 18:00:00 +0100 Subject: [PATCH 04/19] feat: add matchbook Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 + README.md | 5 +- docs/api.md | 30 +- docs/capabilities.md | 120 +- docs/streaming.md | 19 + docs/trading.md | 57 + synpath/__init__.py | 10 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 6 + synpath/ids.py | 5 +- synpath/matchbook.py | 1029 ++++++++++++++++ synpath/server/serve.py | 3 +- synpath/trading/__init__.py | 4 + synpath/trading/__main__.py | 8 + synpath/trading/credentials.py | 31 +- synpath/trading/init.py | 9 + synpath/trading/instruments.py | 6 + synpath/trading/matchbook.py | 842 +++++++++++++ synpath/types.py | 5 +- synpath/ws/__init__.py | 5 +- synpath/ws/matchbook.py | 397 ++++++ tests/samples/matchbook_closed.json | 98 ++ tests/samples/matchbook_errors.json | 14 + tests/samples/matchbook_events.json | 1670 ++++++++++++++++++++++++++ tests/samples/matchbook_prices.json | 94 ++ tests/samples/matchbook_sports.json | 25 + tests/samples/matchbook_trading.json | 606 ++++++++++ tests/test_init.py | 35 +- tests/test_matchbook.py | 524 ++++++++ tests/test_matchbook_live.py | 45 + tests/test_matchbook_trading.py | 444 +++++++ tests/test_matchbook_ws.py | 211 ++++ 33 files changed, 6309 insertions(+), 63 deletions(-) create mode 100644 synpath/matchbook.py create mode 100644 synpath/trading/matchbook.py create mode 100644 synpath/ws/matchbook.py create mode 100644 tests/samples/matchbook_closed.json create mode 100644 tests/samples/matchbook_errors.json create mode 100644 tests/samples/matchbook_events.json create mode 100644 tests/samples/matchbook_prices.json create mode 100644 tests/samples/matchbook_sports.json create mode 100644 tests/samples/matchbook_trading.json create mode 100644 tests/test_matchbook.py create mode 100644 tests/test_matchbook_live.py create mode 100644 tests/test_matchbook_trading.py create mode 100644 tests/test_matchbook_ws.py diff --git a/.env.example b/.env.example index 0934e99..3e5992f 100644 --- a/.env.example +++ b/.env.example @@ -114,3 +114,11 @@ BETFAIR_JURISDICTION= # prohibited. SMARKETS_USERNAME= SMARKETS_PASSWORD= + +# Matchbook (market data, a polled stream, and order entry): market data needs +# no login at all. Betting logs in with your Matchbook username and password; +# an account with two-factor login cannot log in from here. Every GET is +# chargeable on Matchbook's API plans (£100 per million a month), and its terms +# allow viewing prices and betting on your own account only. +MATCHBOOK_USERNAME= +MATCHBOOK_PASSWORD= diff --git a/README.md b/README.md index 649c0d2..f236f6f 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,14 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | +| | [Matchbook](https://www.matchbook.com) | `matchbook` | ✓ no account | ✓ your own login | polled from REST | one book per runner, back and lay read as YES | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). +Matchbook, a betting exchange licensed in the UK and Alderney, is read the same way: each runner is a yes/no market, `matchbook:--`, and the Matchbook market holding the runners is the event. Its market data needs no account; betting logs in with your own Matchbook username and password. Every GET is chargeable on Matchbook's API plans (£100 per million a month), and its terms allow viewing prices and betting on your own account only. See [capabilities](docs/capabilities.md#matchbook). + ## Why Synpath - **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. @@ -135,7 +138,7 @@ fee.estimate(price=0.50, contracts=100) # 1.75 **Same code, every venue** ```python -for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets'] (Betfair and predict.fun need keys) +for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets', 'matchbook'] (Betfair and predict.fun need keys) with synpath.exchange(venue_id) as venue: page = venue.fetch_markets(limit=5) diff --git a/docs/api.md b/docs/api.md index dfbf90e..e398bc3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,11 +77,11 @@ and spot exchanges do not. | Method | Returns | Notes | |---|---|---| | `fetch_markets(query, limit, cursor, status, sort)` | `Page[Market]` | `limit` up to 100 | -| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read), order preserved, closed and settled markets included | +| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read; Matchbook 20 events a request, every market of each), order preserved, closed and settled markets included | | `fetch_events(query, limit, cursor, status)` | `Page[Event]` | markets nested | | `fetch_market(market_id)` | `Market` | | | `fetch_order_book(market_id, side, depth)` | `OrderBook` | `side="yes"` (default) or `"no"` | -| `fetch_order_books(market_ids, side, depth)` | `dict[str, OrderBook]` | many books; one request on Polymarket (500 books), on Betfair (40 Betfair markets, every runner of each) and on Smarkets (200 Smarkets markets, every contract of each), one per market elsewhere | +| `fetch_order_books(market_ids, side, depth)` | `dict[str, OrderBook]` | many books; one request on Polymarket (500 books), on Betfair (40 Betfair markets, every runner of each) on Smarkets (200 Smarkets markets, every contract of each) and on Matchbook (20 Matchbook events, every runner of each), one per market elsewhere | | `fetch_trades(market_id, since, limit, cursor)` | `Page[Trade]` | newest page first, each page oldest first; `since` in ms; `next_cursor` null at the end | | `fetch_ohlcv(market_id, timeframe, since, until, limit)` | `list[Candle]` | in the YES price; check `price_source`. With `since`, the first `limit` bars from it; without, the newest `limit` | | `fetch_series(series_id)` | `Series` | Kalshi only | @@ -122,6 +122,24 @@ book.info["bids"], book.info["offers"] # Smarkets' own [price bps, quantity] smk.fetch_trades(contracts[0].id) # the short recent tape ``` +Matchbook needs no account. It adds `fetch_event(id)` (one Matchbook market +with its runners, open or closed), `fetch_sports()`, +`fetch_matchbook_events(ids)` (Matchbook's own events with their markets and +prices) and `fetch_runner_prices(event_id, market_id, runner_id)`, and takes +`sport` (an id or a name, `"Soccer"`), `tag` (Matchbook's tag url names, +`"england-premier-league"`) and `market_type` (`"one_x_two"`, `"total"`) on +`fetch_markets` and `fetch_events`. It has no text search. `currency=` asks +for prices in USD, EUR, GBP, AUD, CAD or HKD instead of the region's default, +and `depth=` sets the price levels read a side. See +[Matchbook](capabilities.md#matchbook). + +```python +mb = synpath.Matchbook() # no account +runners = mb.fetch_markets(sport="Soccer", market_type="one_x_two", limit=9) +book = mb.fetch_order_book(runners[0].id, depth=5) # lay prices are YES bids, back prices YES asks +book.info["back"], book.info["lay"] # Matchbook's own [odds, available amount] pairs +``` + Polymarket also has `fetch_order_books_by_token(token_ids, depth)`, by CLOB token id. @@ -204,7 +222,7 @@ result.matched # MarketLink | None Matching covers every venue this library speaks: `kalshi`, `polymarket`, `polymarket_us`, `opinion`, `hyperliquid`, `predict_fun`, `limitless` and -`sx_bet` (not Betfair or Smarkets yet). `venue=` asks one of them in particular; without it the answer is +`sx_bet` (not Betfair, Smarkets or Matchbook yet). `venue=` asks one of them in particular; without it the answer is the first venue listing the same question, in the order Polymarket, Kalshi, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Polymarket US. @@ -256,13 +274,15 @@ A venue that cannot filter by one of them raises `NotSupported`. Polymarket does for `settled`, since its catalog does not publish resolution status, and Betfair for everything but `open`, since a closed market leaves its catalogue (read it by id instead). Smarkets filters on its events' states: `closed` is an -ended or suspended event, `settled` a settled or cancelled one. +ended or suspended event, `settled` a settled or cancelled one. Matchbook +lists only open events with their markets and raises `NotSupported` for the +rest (read a closed market by id). Anything outside the four raises `BadRequest` rather than being handed to the venue to interpret for itself. ### Identifiers -One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). +One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. On Matchbook a market is a runner, `matchbook:--` (`matchbook:34569494493900023-34569495835700023-34569495836701023`), and its event the Matchbook market, `matchbook:-`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). ### Paging diff --git a/docs/capabilities.md b/docs/capabilities.md index b521b2a..c012c3f 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -6,21 +6,21 @@ Read `has` before you call. A capability a venue lacks raises `NotSupported` rather than returning an empty list, so "cannot" is never mistaken for "there is nothing". -| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | -|---|---|---|---|---|---|---|---|---|---|---| -| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | -| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | -| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | -| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | -| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | -| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | -| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | -| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | -| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | -| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | -| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | -| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | -| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | `matchbook` | +|---|---|---|---|---|---|---|---|---|---|---|---| +| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | yes, each runner a market, Matchbook events by start time with every market priced in one request; filter by sport, tag or market type; no login | +| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | yes, one per Matchbook market (all its runners); the fixture or race is `info["matchbook_event"]` | +| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | yes, open or closed; a closed market costs three more reads (Matchbook lists its runners separately) | +| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | yes, the runner's prices: lay prices are YES bids, back prices YES asks, three levels a side by default; NO mirrored | +| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | yes, twenty Matchbook events a request, every runner of each | +| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | no: Matchbook publishes no trades, only the volume matched per runner | +| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | no | +| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | +| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | yes, 2% on net market winnings (UK and Ireland; 4% elsewhere, pass `commission=`) | +| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | no: the events API has no text search; filter by `sport` or `tag` | +| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | yes, twenty events a request, every market of each | +| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | no: the venue lists by start time only | +| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | no | Polymarket US is the CFTC-regulated exchange, not the on-chain CLOB the `polymarket` adapter reads; the two share a brand and nothing else. It has @@ -182,6 +182,67 @@ betting on your own account; odds scraping is prohibited. Which countries Smarkets refuses API use from is not published with its API documentation and is unverified here. +### Matchbook + +Matchbook is a betting exchange licensed in the UK and Alderney, read as YES/NO +markets the way Betfair is. A Matchbook *market* ("Match Odds" on Arsenal vs +Everton) holds *runners* (Arsenal, Everton, the Draw); each runner is a Synpath +market, `matchbook:--`, since Matchbook +addresses a runner under its event and market, and the Matchbook market is its +event, `matchbook:-`. The Matchbook event (the fixture or +race, which holds dozens of markets) is in `info["matchbook_event"]`. + +Back and lay read as YES the way Betfair's do. A `back` price is one a taker +can back at, a resting lay, so it is a YES **ask** at 1/odds; a `lay` price is +one a taker can lay at, a resting back, so it is a YES **bid**. +`available-amount` is the backer's stake on both sides, so a level of stake S +at odds O is S·O contracts paying 1 each. Prices are requested as decimal odds +on a back/lay book whatever the account prefers, and binary markets (totals, +handicaps, yes/no props) include the prices mirrored from the other runner, as +Matchbook shows them. The venue's own rows stay in the book's `info["back"]` +and `info["lay"]`, with the price currency in `info["currency"]`: the one +Matchbook picks for the request's region, unless the adapter's `currency=` +asks for USD, EUR, GBP, AUD, CAD or HKD. + +Each runner carries the volume matched on it (`stats.volume_total`, in the +currency); Matchbook publishes no individual trades and no last traded price, +so there is no `fetch_trades` and no candles. + +Events, markets and runners are `open`, `suspended` or `closed`, an event also +`graded`; a runner `paid` once its bets are settled, and a withdrawn runner +(a non-runner) carries `withdrawn: true`. Suspended is still `open` here, with +`active=False`. A withdrawn runner is `settled` with `info["result"]` `void`; +Matchbook publishes no other result, so a closed market is `closed`. Only open +events are listed, each with its markets; a closed market is read by id. A +total or handicap on a whole line (`2`, `-1`) can push and an Asian line +(`-0.5/1.0`) can half-win, so those runners are `market_type="unknown"`; +half-point lines are binary. The line is `info["line"]` (`info["asian_handicap"]` +for an Asian one). + +Commission is 2% of an account's net winnings in each Matchbook market at +settlement for customers in the UK, Ireland, the Channel Islands and the Isle +of Man, and 4% elsewhere, by Matchbook's terms (`net_market_winnings`); pass +`commission=0.04` if that is yours. API and commercial accounts can be on +volume commission instead; each offer names its `commission-type`. + +**No account for market data.** Every read here is an anonymous GET. The read +adapter sends a session only when given one (`credentials=`, `session_token=`, +or `login=True` to read `MATCHBOOK_USERNAME` and `MATCHBOOK_PASSWORD`), since +a session counts the reads against the account. Betting logs in; see +[trading](trading.md#matchbook). + +**What it costs.** Matchbook rate-limits each account or IP by group: events +700 requests a minute, navigation 100, reports 40, account 300, betting +writes 3,000. Past a limit it blocks the caller for ten minutes. On its API +plans every GET is chargeable, £100 per million in a calendar month; writes +are free at a reasonable rate. Reads here are batched (twenty events a +request, every market and price in them) to keep the count down. + +**What you may do with it.** Matchbook's terms allow viewing prices and +betting on your own account. Redistributing its prices, statuses or event data +to third parties, or showing them in a public price comparison, needs +Matchbook's written consent. + Matching spans venues, so no adapter claims it. `synpath.match_market(id)` and `synpath.match_event(id)` are answered by Synpath's hosted matching service at `api.synpath.dev`, with a Synpath API key (the one `synpath keys create` saved, `SYNPATH_API_KEY`, or `api_key=`). See @@ -209,20 +270,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | -|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | -| `rfq` | yes | no | no | no | no | no | no | no | no | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `matchbook` | +|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | yes, odds and remaining stake in place | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes, while the market is unsettled | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | partial: offers on unsettled markets, by status | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | partial: the matched bets on unsettled markets | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, current matched bets netted per runner; the account's balance, exposure and free funds | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | yes, from the settled-bets report | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, commission on what the bet wins, makers and takers alike | +| `rfq` | yes | no | no | no | no | no | no | no | no | no | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -258,6 +319,7 @@ Every stream answers the `watch_*` keys the same way | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | +| `MatchbookMarketStream` | partial: polled from REST, every 5 s by default | partial: polled | no: no public trades | partial: polled | no | no | no | no | The exchange API's other gRPC streams carry one thing each and say so: drop copy and trade capture carry fills, position and position-change carry diff --git a/docs/streaming.md b/docs/streaming.md index 7a577f6..e8e2713 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -43,6 +43,7 @@ asyncio.run(main()) | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | +| `MatchbookMarketStream` | `api.matchbook.com` REST, polled (Matchbook has no push API) | none | runner books, top of book and runner status at every poll (5 s by default) | Each stream's `has` answers the `watch_*` capability keys (`watch_order_book`, `watch_ticker`, `watch_trades`, `watch_market_status`, `watch_orders`, @@ -69,6 +70,7 @@ variable `synpath serve` reads it from: | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | | Betfair | the application key and a login (every Betfair call needs one) | `BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD` | | Smarkets | nothing; an optional login undelays the quotes | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | +| Matchbook | nothing | | `synpath serve` builds a market stream for Opinion, predict.fun and SX Bet from the API key alone (`load_market_data`), so a parent on any venue can @@ -204,6 +206,23 @@ jittered exponential backoff between attempts. says so in `info["delayed"]`. A change shows at the next poll, not as it happens, so its `watch_*` keys are `partial`. No `watch_trades`: the recent tape is short and unpaged; read it with `Smarkets.fetch_trades`. +- *Matchbook* has no push API: no WebSocket, SSE or FIX. `MatchbookMarketStream` + polls `GET /edge/rest/events` with prices, twenty Matchbook events a + request, and emits the difference between one answer and the next: a + snapshot first, then deltas of the levels that changed, nothing when + nothing did. One read carries every market, runner and price of an event, + so books, tickers and runner status (suspended, closed, withdrawn) come from + the same polls. Polls share the adapter's `LIMITER` (Matchbook's events + group allows 700 requests a minute). Every GET is chargeable on Matchbook's + API plans (£100 per million a month): the default 5 s interval is 12 + requests a minute for up to twenty events, about 520,000 a month if left + running, so raise `interval` to spend less. The stream polls without a + session, so the polls are not the account's. A refused or failed poll is a + dropped connection: the base loop backs off and reconnects, and the next + poll rebuilds every book and reports it `resynced`. A change shows at the + next poll, so its `watch_*` keys are `partial`. Matchbook publishes no + trades, so there is no `watch_trades`, and no account stream: the engine + polls the account's offers. **A book is right or marked not ready.** After a gap or a disconnect the book's `ready` is false until a snapshot arrives; deltas are not applied to diff --git a/docs/trading.md b/docs/trading.md index 27a5edb..67c8723 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -71,6 +71,7 @@ private key. `synpath init` tells them apart. | predict.fun | mainnet: the API key (`pred_sk_...`). Test network: nothing | the API key and a private key: a plain wallet's, or a Predict account's owner key with its deposit address | the key at developers.predict.fun; a web-app account's owner key under Account -> Settings -> Privy wallet | | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | +| Matchbook | nothing | the account's login: username and password (an account with two-factor login passes a session token made elsewhere) | your matchbook.com account; higher API limits need an API plan from api@matchbook.com | For Opinion, predict.fun and SX Bet the API key alone is a complete market-data setup: `synpath init` asks for it first and takes an empty @@ -95,6 +96,7 @@ The variables each venue reads: | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | +| Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, optional `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` and `BETFAIR_JURISDICTION`) @@ -573,3 +575,58 @@ each market on the YES leg. The balance is the proxy wallet's USDC: what is free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. + +## Matchbook + +`MatchbookTrading`, built to Matchbook's API reference +(developers.matchbook.com). Every call carries the session token of an +account login (`MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD`), made on the first +call and kept in memory only. A session lasts about six hours: one older than +five is replaced before the next call, and a 401 logs in again once. An +account with two-factor login cannot log in from here; pass +`session_token=` from a login made elsewhere. + +An order is an offer to back or lay one runner, +`matchbook:--`. `buy` backs it (takes its +YES) at decimal odds `1 / price`; `sell` lays it (takes its NO) at the same +odds. Matchbook counts both in the backer's stake, so `n` contracts stake +`n / odds`, rounded down to the penny; a lay's liability is that stake times +`odds - 1`. Prices are snapped onto Matchbook's odds ladder (1.01 to 1000, the +same bands as Betfair's) on the cheaper side: a buy at the next higher odds, a +sell at the next lower, so an order never pays more than asked. + +Limits rest until matched or cancelled (`gtc`). Matchbook has no +immediate-or-cancel offer, so `market` and `ioc` are offers at the price +given as the worst accepted, whose unmatched rest is cancelled at once; in +play, the offer first waits out Matchbook's delay (1 to 10 seconds), then +the rest is cancelled. `fok`, `gtd` and post-only are refused, and so is +`reduce_only`, since every bet on a runner nets. An offer's unmatched part is +cancelled when its event goes in play unless it is sent with +`params={"keep_in_play": True}`. `create_orders` sends fifty offers a request +and returns one result each. `edit_order` changes the odds or the remaining +stake in place (`quantity` is the order's new total, filled contracts +included). + +Matchbook takes no client order id: `client_order_id` stays on the returned +order only. After a timeout, read the open offers on the runner +(`fetch_open_orders(market_id=...)`) to see what was placed. + +Offers and their matched bets are readable while their market is unsettled: +`fetch_order`, `fetch_open_orders`, `fetch_orders` (by status) and +`fetch_my_trades` (one fill per matched bet). Settled bets are +`fetch_settlements`. Positions net the account's current matched bets per +runner on the YES leg: backs are YES contracts, lays NO contracts. The +balance is the account's cash, what its bets hold (exposure and the +commission reserve, `locked`), and what is free to bet, in the account's +currency. + +`heartbeat(seconds)` arms Matchbook's dead man's switch: unless it is called +again in time, every open offer on the account is cancelled; `heartbeat(0)` +turns it off. Commission is charged at settlement on net winnings per market +(2% in the UK and Ireland, 4% elsewhere), makers and takers alike, unless the +account is on volume commission. Betting writes are free at a reasonable +rate; reads count towards the GETs Matchbook's API plans charge for. + +Check Matchbook's terms for where it may be used before trading: it is +licensed in the UK and Alderney, and its terms allow betting on your own +account only. diff --git a/synpath/__init__.py b/synpath/__init__.py index b43731a..41eb404 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -49,6 +49,7 @@ from .sx_bet import SxBet from .betfair import Betfair from .smarkets import Smarkets +from .matchbook import Matchbook from .matching import match_event, match_market from .history import fetch_order_book_at, fetch_order_book_range, fetch_trades_range from .bucket import Bucket, BucketMember, BucketOrderReport, BucketPosition @@ -95,6 +96,7 @@ "sx_bet": SxBet, "betfair": Betfair, "smarkets": Smarkets, + "matchbook": Matchbook, } """Every venue this library speaks, by id.""" @@ -115,7 +117,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, MatchbookTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -145,7 +147,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "MatchbookMarketStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -167,7 +169,7 @@ def __getattr__(name: str): __all__ = [ - "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Exchange", "exchange", "exchanges", + "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Matchbook", "Exchange", "exchange", "exchanges", "Capability", "HttpClient", "RateLimiter", "Market", "Event", "Outcome", "Quote", "MarketStats", "OrderBook", "OrderLevel", "Trade", "Candle", "FeeSchedule", "Series", "Page", @@ -184,7 +186,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "MatchbookTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index 20f063a..1534163 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -383,7 +383,13 @@ def _sx_bet(creds: Credentials) -> TradingExchange: return SxBetTrading(creds) # type: ignore[arg-type] +def _matchbook(creds: Credentials) -> TradingExchange: + from .trading.matchbook import MatchbookTrading + return MatchbookTrading(creds) # type: ignore[arg-type] + + _TRADING = { "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, + "matchbook": _matchbook, } diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..eba7600 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -65,6 +65,7 @@ "predict_fun": ("..trading.predict_fun", "PredictFunTrading"), "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), + "matchbook": ("..trading.matchbook", "MatchbookTrading"), } diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..20de793 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -164,6 +164,12 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] # login, when configured, only undelays the quotes. from ..ws.smarkets import SmarketsMarketStream out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) + elif venue == "matchbook": + # Polled, as Matchbook has no push API: books from public REST + # reads, with no session so the polls are not the account's. No + # account stream; the engine polls the offers. + from ..ws.matchbook import MatchbookMarketStream + out[venue] = VenueStreams(market=MatchbookMarketStream(), private=None) else: log.info("synpath.engine.feeds: no stream for %s; its orders reach the engine by polling", venue) return out diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..947e53b 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -4,7 +4,8 @@ the venue's own id, separated by a colon: `kalshi:KXFEDDECISION-26SEP-C25`, `polymarket:2252244`, `polymarket_us:tec-mlb-nlchamp-2026-09-27-atl`, `opinion:5342`, `hyperliquid:7173`, `betfair:1.234567890-47972`, -`smarkets:164973231-456060183`. Nothing is hashed or re-keyed, so the part +`smarkets:164973231-456060183`, +`matchbook:34569494493900023-34569495835700023-34569495836701023`. Nothing is hashed or re-keyed, so the part after the colon is what the venue's own website and API call the thing. The venue in the id is what lets one field route a call: `synpath.Client` @@ -16,7 +17,7 @@ """ from __future__ import annotations -VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") +VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets", "matchbook") """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.""" diff --git a/synpath/matchbook.py b/synpath/matchbook.py new file mode 100644 index 0000000..cc75c4e --- /dev/null +++ b/synpath/matchbook.py @@ -0,0 +1,1029 @@ +"""Matchbook: a back/lay betting exchange, read from its public REST API. + +**No account is needed for market data.** Sports, events, markets, runners +and their prices all answer an anonymous GET on `api.matchbook.com`. A login +(`MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD`) is what betting needs, and the +trading adapter (`synpath.trading.matchbook`) makes it; this adapter sends a +session only when given one (`credentials=`, `session=`, or `login=True` to +read the environment). See `MatchbookSession`. + +Five things about this venue that shape the adapter: + +**A Matchbook market is many runners, and each runner is a Synpath market.** +"Match Odds" on Arsenal vs Everton has three runners: Arsenal, Everton, the +Draw. Each answers its own yes/no question and is a Synpath market, +`matchbook:34569494493900023-34569495835700023-34569495836701023` (event id, +market id, runner id: Matchbook addresses a runner under its event and market, +so the id carries all three). The Matchbook market with all its runners is the +Synpath event, `matchbook:-`, as a Betfair market is. The +Matchbook *event* (the fixture or race) is the parent of that grouping and +rides in `info["matchbook_event"]`. + +**Back and lay, read as YES, the way Betfair reads them.** Backing a runner at +decimal odds O is buying its YES at 1/O; laying it is selling YES at 1/O. +Matchbook names each price after what a *taker* can do with it: a `back` +price is one a taker can back at, a resting lay, so on YES it is an **ask**; +a `lay` price is one a taker can lay at, a resting back, so it is a **bid**. +The best back price (the highest odds) sits below the best lay price, which +keeps the YES bid under the YES ask. + +**Sizes are backer's stakes in the price's currency.** `available-amount` is +the stake a backer can match at that price, on both sides (a lay price's +amount is the backer's stake laid against, not the layer's liability: a back +of £879.92 at 1.04 on one side of a two-way market mirrors a lay of £35.19 at +26 on the other, both 915 contracts). A stake S at odds O is S·O contracts +paying 1 each, so every level is converted that way, and the venue's own odds +and amounts stay in the book's `info` (`back`, `lay`). The currency is the +one Matchbook picks for the request's region unless `currency=` asks for +another; every book and quote records it. + +**No public trades.** Matchbook publishes the volume matched per runner, never +individual trades, so `fetch_trades` and candles are not offered. + +**Commission on net market winnings.** Matchbook charges a percentage of an +account's net winnings in each market at settlement: 2% for customers in the +UK, Ireland, the Channel Islands and the Isle of Man, 4% elsewhere, by its +terms. See `FeeSchedule.estimate` (`net_market_winnings`). + +Every GET is chargeable on Matchbook's API plans (£100 per million a calendar +month) and rate limited per account or IP by group: events 700 a minute, +navigation 100, reports 40. A limit exceeded blocks the caller for ten +minutes. Reads here are batched to keep the count down. Matchbook's terms +allow viewing prices and betting on your own account; redistributing its +prices or event data needs its written consent. +""" +from __future__ import annotations + +import threading +import time +from datetime import datetime +from decimal import Decimal +from typing import Any, Iterator + +import httpx + +from . import ids +from .base import ( + MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort, check_status, page_limit, +) +from .betfair import CLASSIC_LADDER, contracts, yes_price +from .errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported, RateLimitExceeded +from .smarkets import user_agent +from .types import BookSide, Event, FeeSchedule, Market, MarketStats, OrderBook, OrderLevel, Outcome, Page, Quote, iso + +VENUE = "matchbook" +API_URL = "https://api.matchbook.com" +EDGE = "/edge/rest" +SESSION_PATH = "/bpapi/rest/security/session" + +LIMITER = RateLimiter(5.0, burst=10) +"""Matchbook's events group allows 700 requests a minute per account or IP; +five a second, shared process-wide, stays well inside it.""" + +NAVIGATION_LIMITER = RateLimiter(1.0, burst=3) +"""The navigation group (sports, the navigation tree) allows 100 a minute.""" + +SESSION_REFRESH_AFTER = 5 * 3600.0 +"""A session lasts about six hours; one older than this is replaced by a fresh +login before the next call rather than reused until it fails.""" + +ODDS_LADDER: tuple[Decimal, ...] = CLASSIC_LADDER +"""Every decimal price Matchbook's exchange ladder has, 1.01 to 1000 in the +same bands as Betfair's CLASSIC ladder (350 ticks). Offers are placed on it; +mirrored prices on binary markets can sit between its ticks.""" + +PRICE_DEPTH = 3 +"""Price levels a side read by default, Matchbook's own default.""" + +EVENT_PAGE = 20 +"""Matchbook events read per listing request, each with every market and its +prices: a football fixture carries forty or so markets.""" + +EVENTS_PER_REQUEST = 20 +"""Events one by-id request asks for (`ids=`), so a page holds them all.""" + +MAX_PAGES = 20 +"""Most event pages one listing call reads while filling a page.""" + +OPEN_STATES = "open,suspended" +ALL_STATES = "open,suspended,closed,graded" +"""Matchbook event and market states. A closed or graded event is listed +without its markets; those are read by id.""" + +RUNNER_STATES = "open,suspended,closed,paid" +"""Runner states, for reading the runners of a closed market (Matchbook lists +only open and suspended ones by default).""" + +DEFAULT_COMMISSION = 0.02 +"""Matchbook's commission on net market winnings for UK, Irish, Channel +Islands and Isle of Man customers; 4% elsewhere (pass `commission=0.04`).""" + +PRICE_PARAMS = {"odds-type": "DECIMAL", "exchange-type": "back-lay"} +"""Prices as decimal odds on a back/lay book, whatever the account prefers.""" + +TAG_ORDER = ("SPORT", "COMPETITION", "COUNTRY", "LOCATION") +"""Which of an event's meta-tags become Synpath tags, in this order. DATE and +OTHER tags ("Live Betting", "October 24th 2026") are left out.""" + + +# --------------------------------------------------------------------------- +# Ids +# --------------------------------------------------------------------------- + +def runner_native_id(event_id: Any, market_id: Any, runner_id: Any) -> str: + """`--`.""" + return f"{event_id}-{market_id}-{runner_id}" + + +def market_native_id(event_id: Any, market_id: Any) -> str: + """`-`: a Matchbook market, the Synpath event.""" + return f"{event_id}-{market_id}" + + +def _parts(native: str) -> list[str]: + parts = native.split("-") + if not all(part.isdigit() for part in parts): + raise BadRequest(f"{VENUE}: {native!r} is not a Matchbook id") + return parts + + +def parse_runner_id(native: str) -> tuple[str, str, str]: + """(event id, market id, runner id) from a runner's native id.""" + parts = native.split("-") + if len(parts) != 3 or not all(part.isdigit() for part in parts): + raise BadRequest( + f"{VENUE}: {native!r} is not a runner id; expected '--' " + f"(a '-' pair names an event)" + ) + return parts[0], parts[1], parts[2] + + +def parse_market_id(native: str) -> tuple[str, str]: + """(event id, market id) from a runner id or a market pair.""" + parts = _parts(native) + if len(parts) not in (2, 3): + raise BadRequest(f"{VENUE}: {native!r} is not a Matchbook market; expected '-'") + return parts[0], parts[1] + + +# --------------------------------------------------------------------------- +# Pure normalizers +# --------------------------------------------------------------------------- + +def iso_ms(value: Any) -> int | None: + if not value: + return None + try: + return int(datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return None + + +def ladder_levels(prices: Any, side: str) -> list[OrderLevel]: + """One side's price rows as YES levels: price 1/O, size in contracts.""" + out = [] + for row in prices or []: + if not row or row.get("side") != side: + continue + odds, amount = row.get("decimal-odds") or row.get("odds"), row.get("available-amount") + if not odds or not amount or float(odds) <= 1 or float(amount) <= 0: + continue + out.append(OrderLevel(price=yes_price(odds), size=contracts(amount, odds))) + return out + + +def raw_levels(prices: Any, side: str) -> list[list[float]]: + """The venue's own [odds, available amount] pairs on one side.""" + return [[row.get("decimal-odds") or row.get("odds"), row.get("available-amount")] + for row in prices or [] if row and row.get("side") == side] + + +def price_currency(prices: Any) -> str | None: + return next((str(row["currency"]) for row in prices or [] if row and row.get("currency")), None) + + +def quotes_of(runner: dict[str, Any] | None) -> tuple[Quote, Quote]: + """YES and NO top of book from a runner's prices. Matchbook publishes no + last traded price, so `last` stays empty.""" + prices = (runner or {}).get("prices") + bids, asks = ladder_levels(prices, "lay"), ladder_levels(prices, "back") + bid = max(bids, key=lambda level: level.price, default=None) + ask = min(asks, key=lambda level: level.price, default=None) + mid = round((bid.price + ask.price) / 2, 10) if bid and ask else None + yes = Quote(bid=bid.price if bid else None, bid_size=bid.size if bid else None, + ask=ask.price if ask else None, ask_size=ask.size if ask else None, mid=mid) + no = Quote(bid=round(1 - ask.price, 10) if ask else None, bid_size=ask.size if ask else None, + ask=round(1 - bid.price, 10) if bid else None, ask_size=bid.size if bid else None, + mid=round(1 - mid, 10) if mid is not None else None) + return yes, no + + +def status_of( + event_status: str | None, market_status: str | None, runner_status: str | None = None, + withdrawn: bool = False, +) -> tuple[str, bool]: + """(normalized status, accepting orders) from the event's, market's and + runner's statuses. + + A suspended market or runner is still open: Matchbook suspends for a goal + or the off of a race and reopens, which is a pause, not a change of + lifecycle. A runner withdrawn (a non-runner, `paid` once its bets are + voided) is settled though its market trades on. A closed market has + stopped trading; Matchbook does not publish its result.""" + if withdrawn or runner_status == "paid": + return "settled", False + if "graded" in (event_status, market_status): + return "settled", False + if "closed" in (event_status, market_status, runner_status): + return "closed", False + if "suspended" in (event_status, market_status, runner_status): + return "open", False + if market_status == "open" and runner_status in (None, "open"): + return "open", True + return "open", False + + +def result_of(runner: dict[str, Any]) -> str | None: + """`void` for a withdrawn runner; Matchbook publishes no other result.""" + return "void" if runner.get("withdrawn") else None + + +def plain_line(market: dict[str, Any]) -> bool: + """Whether every bet on the market either wins or loses. An Asian line + (`-0.5/1.0`) can half-win, and a whole line (a handicap of -1, a total of + 2) can push and be refunded, so neither is a plain yes/no contract.""" + if market.get("asian-handicap"): + return False + line = market.get("handicap") + if line is None or market.get("type") != "binary": + return True + return (Decimal(str(line)) * 2) % 2 == 1 + + +def _tags(event: dict[str, Any]) -> list[str]: + tags = [t for t in event.get("meta-tags") or [] if isinstance(t, dict) and t.get("type") in TAG_ORDER] + tags.sort(key=lambda t: TAG_ORDER.index(t["type"])) + seen: dict[str, None] = {} + for tag in tags: + name = str(tag.get("name") or "").strip() + if name: + seen.setdefault(name.lower(), None) + return list(seen) + + +def _event_info(event: dict[str, Any] | None) -> dict[str, Any] | None: + """The Matchbook event without its markets.""" + if not event: + return None + return {key: value for key, value in event.items() if key != "markets"} + + +def _close(market: dict[str, Any]) -> int | None: + """When trading is expected to stop: the start, for a market that does not + turn in-play. One that bets in-play has no known close until it closes.""" + if market.get("allow-live-betting"): + return None + return iso_ms(market.get("start")) + + +def normalize_market( + event: dict[str, Any] | None, market: dict[str, Any], runner: dict[str, Any], *, currency: str | None = None, +) -> Market: + """One runner of a Matchbook market as a binary market: YES is "this + runner's bet wins".""" + event = event or {} + event_id = str(market.get("event-id") or event.get("id")) + market_id, runner_id = str(market["id"]), str(runner["id"]) + native = runner_native_id(event_id, market_id, runner_id) + event_status, market_status, runner_status = event.get("status"), market.get("status"), runner.get("status") + withdrawn = bool(runner.get("withdrawn")) + status, accepting = status_of(event_status, market_status, runner_status, withdrawn) + yes_quote, no_quote = quotes_of(runner) + name = str(runner.get("name") or runner_id) + close = _close(market) + tags = _tags(event) + matched = runner.get("volume") + return Market( + id=ids.qualify(VENUE, native), + venue=VENUE, + venue_market_id=native, + event_id=ids.qualify(VENUE, market_native_id(event_id, market_id)), + title=" — ".join(str(part) for part in (event.get("name"), market.get("name"), name) if part), + description=None, + slug=None, + yes=Outcome(label=name, quote=yes_quote), + no=Outcome(label=f"Not {name}", quote=no_quote), + status=status, # type: ignore[arg-type] + native_status=f"{market_status}/{runner_status}" if market_status or runner_status else None, + active=accepting, + market_type="binary" if plain_line(market) else "unknown", + open_timestamp=None, + open_datetime=None, + close_timestamp=close, + close_datetime=iso(close), + resolution_timestamp=None, + resolution_datetime=None, + # The ladder is in odds, so its step in YES price varies along it. + tick_size=None, + face_value=1.0, + book_model="shared_complement", + stats=MarketStats( + volume_total=float(matched) if matched else None, + volume_unit="collateral" if matched else None, + ), + url=None, + image_url=None, + category=(tags or [None])[0], + tags=tags, + series_id=None, + outcome_label=name, + neg_risk=False, + settlement_sources=[], + info={ + "event_id": event_id, + "market_id": market_id, + "runner_id": runner_id, + "runner": {key: value for key, value in runner.items() if key != "prices"}, + "market": {key: value for key, value in market.items() if key != "runners"}, + "matchbook_event": _event_info(event), + "event_status": event_status, + "market_status": market_status, + "runner_status": runner_status, + "withdrawn": withdrawn, + "result": result_of(runner), + "in_play": bool(market.get("live")), + "allow_live_betting": market.get("allow-live-betting"), + "market_type": market.get("market-type"), + "line": runner.get("handicap", market.get("handicap")), + "asian_handicap": runner.get("asian-handicap") or market.get("asian-handicap"), + "number_of_winners": market.get("number-of-winners"), + "market_volume": market.get("volume"), + "last_price_update": iso_ms(runner.get("last-price-update-time")), + "start": iso_ms(market.get("start") or event.get("start")), + "currency": price_currency(runner.get("prices")) or currency, + }, + ) + + +def normalize_event(event: dict[str, Any] | None, market: dict[str, Any], *, currency: str | None = None) -> Event: + """A Matchbook market with its runners: the grouping of runner markets.""" + event = event or {} + event_id = str(market.get("event-id") or event.get("id")) + market_id = str(market["id"]) + markets = [normalize_market(event, market, runner, currency=currency) for runner in market.get("runners") or []] + statuses = {m.status for m in markets} + status = next((s for s in ("open", "unopened", "closed", "settled") if s in statuses), None) + if status is None: + status, _ = status_of(event.get("status"), market.get("status")) + winners = market.get("number-of-winners") + close = _close(market) + tags = _tags(event) + return Event( + id=ids.qualify(VENUE, market_native_id(event_id, market_id)), + venue=VENUE, + venue_event_id=market_native_id(event_id, market_id), + title=" — ".join(str(p) for p in (event.get("name"), market.get("name")) if p) or market_id, + description=None, + slug=None, + markets=markets, + status=status, # type: ignore[arg-type] + native_status=market.get("status"), + category=(tags or [None])[0], + tags=tags, + series_id=None, + # One winner among the runners is exclusive; a place market or a line + # that can push is not. + mutually_exclusive=(winners == 1) if winners is not None and plain_line(market) else None, + close_timestamp=close, + close_datetime=iso(close), + url=None, + image_url=None, + settlement_sources=[], + info={ + "matchbook_event": _event_info(event), + "event_id": event_id, + "market_type": market.get("market-type"), + "type": market.get("type"), + "number_of_winners": winners, + "line": market.get("handicap"), + "asian_handicap": market.get("asian-handicap"), + "in_play": bool(market.get("live")), + "allow_live_betting": market.get("allow-live-betting"), + "volume": market.get("volume"), + "back_overround": market.get("back-overround"), + "lay_overround": market.get("lay-overround"), + "currency": next((price_currency(r.get("prices")) for r in market.get("runners") or [] + if price_currency(r.get("prices"))), currency), + }, + ) + + +def normalize_order_book( + prices: Any, *, market_id: str, side: BookSide = "yes", depth: int | None = None, + currency: str | None = None, info: dict[str, Any] | None = None, +) -> OrderBook: + """A runner's prices read on YES: lay prices (resting backs) are bids, back + prices (resting lays) asks, at 1/O, sizes in contracts. `side="no"` + mirrors it.""" + bids = sorted(ladder_levels(prices, "lay"), key=lambda level: level.price, reverse=True) + asks = sorted(ladder_levels(prices, "back"), key=lambda level: level.price) + derived = side == "no" + if derived: + bids, asks = ( + [OrderLevel(price=round(1 - level.price, 10), size=level.size) for level in asks], + [OrderLevel(price=round(1 - level.price, 10), size=level.size) for level in bids], + ) + elif side != "yes": + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + if depth: + bids, asks = bids[:depth], asks[:depth] + return OrderBook( + market_id=market_id, side=side, venue=VENUE, bids=bids, asks=asks, + timestamp=None, datetime=None, book_model="shared_complement", derived=derived, + # As many levels a side as were asked for (`price-depth`). + depth_scope="top_n", + info={ + "back": raw_levels(prices, "back"), + "lay": raw_levels(prices, "lay"), + "currency": price_currency(prices) or currency, + # Binary markets include the prices mirrored from the other runner. + "mirrored_prices": True, + **(info or {}), + }, + ) + + +def fee_schedule_of(market_id: str, rate: float = DEFAULT_COMMISSION) -> FeeSchedule: + """Commission on net winnings per Matchbook market, makers and takers alike.""" + return FeeSchedule( + venue=VENUE, scope="market", scope_id=market_id, fee_type="net_market_winnings", + taker_rate=rate, maker_rate=rate, + info={ + "charged": "at settlement, on the account's net winnings in the whole Matchbook market", + "rate_source": "Matchbook's UK and Ireland rate" if rate == DEFAULT_COMMISSION else "configured", + "not_included": "the 4% rate outside the UK, Ireland, the Channel Islands and the Isle of Man, " + "and volume commission on API or commercial accounts", + }, + ) + + +def error_messages(body: Any) -> str | None: + """Matchbook's reasons: `{"errors": [{"messages": [...], "codes": [...]}]}`.""" + if not isinstance(body, dict): + return None + texts = [str(m) for error in body.get("errors") or [] if isinstance(error, dict) + for m in error.get("messages") or []] + return "; ".join(texts) or None + + +def error_codes(body: Any) -> list[str]: + if not isinstance(body, dict): + return [] + return [str(c) for error in body.get("errors") or [] if isinstance(error, dict) + for c in error.get("codes") or [] if c] + + +def batched(items: list[str], size: int) -> Iterator[list[str]]: + for start in range(0, len(items), size): + yield items[start:start + size] + + +def start_s(event: dict[str, Any]) -> int: + """An event's start in whole seconds, the unit of the listing's `after`.""" + return (iso_ms(event.get("start")) or 0) // 1000 + + +# --------------------------------------------------------------------------- +# Session +# --------------------------------------------------------------------------- + +class MatchbookSession: + """An account's session token, kept in memory only. + + Logs in on first use with `POST /bpapi/rest/security/session` (username + and password); the token goes on every request as the `session-token` + header. A session lasts about six hours, so one older than + `SESSION_REFRESH_AFTER` is replaced before the next call, and a 401 logs + in again once. Matchbook asks for one login per session, not one per + request; one lock means one login per session object however many + threads ask. An account with two-factor login is refused with a clear + error: log in elsewhere and pass `session_token=`. + """ + + def __init__( + self, *, username: str | None = None, password: str | None = None, session_token: str | None = None, + client: httpx.Client | None = None, timeout: float = 30.0, clock: Any = time.monotonic, + base_url: str = API_URL, + ): + self.username = username + self._password = password + self._token = session_token + self._clock = clock + self._issued = clock() if session_token else 0.0 + self._lock = threading.Lock() + self._client = client + self._timeout = timeout + self.base_url = base_url.rstrip("/") + self.account: dict[str, Any] | None = None + """The account the login answered with (currency, balance), when this + session logged in itself.""" + + def __repr__(self) -> str: + return f"MatchbookSession(username={self.username!r}, token={'set' if self._token else 'none'})" + + def token(self) -> str: + with self._lock: + if not self._token or self._clock() - self._issued > SESSION_REFRESH_AFTER: + self._login() + assert self._token + return self._token + + def headers(self) -> dict[str, str]: + return {"session-token": self.token()} + + def invalidate(self) -> None: + with self._lock: + self._token = None + + def _http(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client(timeout=self._timeout, headers={"User-Agent": user_agent()}) + return self._client + + def _login(self) -> None: + if not self.username or not self._password: + raise AuthenticationError( + f"{VENUE}: no session; set MATCHBOOK_USERNAME and MATCHBOOK_PASSWORD, or pass session_token= " + f"(market data needs neither)" + ) + try: + response = self._http().post( + f"{self.base_url}{SESSION_PATH}", json={"username": self.username, "password": self._password}, + headers={"Accept": "application/json", "Content-Type": "application/json", "User-Agent": user_agent()}, + ) + except httpx.HTTPError as exc: + from .errors import NetworkError + + raise NetworkError(f"{VENUE}: login unreachable: {type(exc).__name__}") from None + try: + body = response.json() + except ValueError: + body = None + if response.status_code == 429: + raise RateLimitExceeded(f"{VENUE}: too many logins; Matchbook blocks for ten minutes past its limit") + if "LOGIN_MFA_REQUIRED" in error_codes(body): + raise AuthenticationError( + f"{VENUE}: the account has two-factor login, which this library does not complete. Log in " + f"elsewhere and pass session_token=" + ) + if response.status_code >= 400: + reason = error_messages(body) or str(response.status_code) + raise AuthenticationError(f"{VENUE}: login refused: {reason}", status=response.status_code, + body={"errors": (body or {}).get("errors") if isinstance(body, dict) else None}) + body = body if isinstance(body, dict) else {} + if not body.get("session-token"): + raise AuthenticationError(f"{VENUE}: the login answered without a session token") + self._token, self._issued = str(body["session-token"]), self._clock() + account = body.get("account") + self.account = account if isinstance(account, dict) else None + + def close(self) -> None: + if self._client is not None: + self._client.close() + + +class MatchbookAuth(httpx.Auth): + """Puts the session token on every request and retries once with a fresh + login when Matchbook answers 401.""" + + requires_response_body = True + + def __init__(self, session: MatchbookSession): + self.session = session + + def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]: + request.headers.update(self.session.headers()) + response = yield request + if response.status_code == 401: + self.session.invalidate() + request.headers.update(self.session.headers()) + yield request + + +def session_from(credentials: Any = None, **overrides: Any) -> MatchbookSession | None: + """A session from credentials, else from `MATCHBOOK_*` in the environment + or a `.env`; `None` when no login is configured.""" + if credentials is None: + credentials = credentials_from_env() + if credentials is None: + return None + return MatchbookSession(username=credentials.username, password=credentials.password, **overrides) + + +def credentials_from_env() -> Any: + import os + + from .trading.credentials import load_matchbook, read_dotenv + + merged: dict[str, str] = {} + if os.path.exists(".env"): + merged.update(read_dotenv(".env")) + merged.update(os.environ) + return load_matchbook(merged) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class Matchbook(Exchange): + """Matchbook, read-only. No account needed. + + ```python + import synpath + + mb = synpath.Matchbook() + markets = mb.fetch_markets(sport="Soccer", limit=10) + book = mb.fetch_order_book(markets[0].id) + book.info["back"] # the venue's own odds and stakes + ``` + """ + + id = VENUE + name = "Matchbook" + book_model = "shared_complement" + has: dict[str, Capability] = { + "fetch_markets": True, + "fetch_events": True, + "fetch_market": True, + # Twenty Matchbook events a request, every market and price of each. + "fetch_markets_by_ids": True, + # Matchbook lists events by start time only. + "sort": False, + "fetch_order_book": True, + "fetch_order_books": True, + # Matchbook publishes no individual trades, only volume per runner. + "fetch_trades": False, + "fetch_ohlcv": False, + "fetch_series": False, + "fetch_fee_schedule": True, + # The events API filters by sport, tag and id; it has no text search. + "search": False, + "match_market": False, + "match_event": False, + } + + def __init__( + self, *, credentials: Any = None, session: MatchbookSession | None = None, session_token: str | None = None, + login: bool | None = None, currency: str | None = None, commission: float = DEFAULT_COMMISSION, + depth: int = PRICE_DEPTH, timeout: float = 30.0, limiter: RateLimiter | None = LIMITER, + navigation_limiter: RateLimiter | None = NAVIGATION_LIMITER, client: Any = None, + session_client: httpx.Client | None = None, base_url: str = API_URL, + ): + """No argument is needed: market data is public, and nothing is sent + until the first call. `credentials`, `session_token` or `login=True` + (`MATCHBOOK_*` from the environment) send a session on every read, + which counts those reads against the account. `currency` asks for + prices in USD, EUR, GBP, AUD, CAD or HKD instead of the region's + default; `depth` is the price levels read a side; `commission` the + rate fee schedules report.""" + overrides: dict[str, Any] = {"client": session_client, "timeout": timeout, "base_url": base_url} + if session is None and session_token: + session = MatchbookSession(session_token=session_token, **overrides) + if session is None and (credentials is not None or login): + session = session_from(credentials, **overrides) + self.session = session + self.currency = currency + self.commission = commission + self.depth = depth + if client is None: + client = httpx.Client(timeout=timeout, follow_redirects=True) + client.headers["User-Agent"] = user_agent() + client.headers["Accept"] = "application/json" + if self.session is not None: + client.auth = MatchbookAuth(self.session) + self.http = HttpClient(base_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + self.navigation_http = HttpClient(base_url, limiter=navigation_limiter, timeout=timeout, client=client, + venue=VENUE) + + def _get(self, path: str, params: Any = None, *, navigation: bool = False) -> Any: + try: + return (self.navigation_http if navigation else self.http).get(f"{EDGE}{path}", params) + except ExchangeError as exc: + reason = error_messages(exc.body) + if reason: + raise type(exc)(f"{VENUE}: {reason}", body=exc.body, status=exc.status) from None + raise + + def _price_params(self, depth: int | None = None) -> dict[str, Any]: + return {**PRICE_PARAMS, "include-prices": "true", "price-depth": depth or self.depth, + "currency": self.currency} + + # -- raw reads ---------------------------------------------------------- + + def fetch_sports(self) -> list[dict[str, Any]]: + """Matchbook's sports, active ones: `[{"name": "Soccer", "id": 15, ...}]`.""" + out: list[dict[str, Any]] = [] + for _ in range(10): + body = self._get("/lookups/sports", {"offset": len(out), "per-page": 100, "order": "name asc"}, + navigation=True) or {} + rows = [s for s in body.get("sports") or [] if isinstance(s, dict)] + out += rows + if not rows or len(out) >= int(body.get("total") or 0): + break + return out + + def fetch_matchbook_events(self, event_ids: list[str], *, depth: int | None = None) -> dict[str, dict[str, Any]]: + """Matchbook events by id with their markets and prices, open or closed + (a closed one comes without markets).""" + out: dict[str, dict[str, Any]] = {} + for chunk in batched(list(dict.fromkeys(event_ids)), EVENTS_PER_REQUEST): + body = self._get("/events", { + "ids": ",".join(chunk), "states": ALL_STATES, "per-page": len(chunk), **self._price_params(depth), + }) or {} + for event in body.get("events") or []: + out[str(event["id"])] = event + return out + + def fetch_runner_prices(self, event_id: str, market_id: str, runner_id: str, *, depth: int | None = None) -> list[dict[str, Any]]: + """One runner's prices, `[{"side": "back", "odds": 2.0, "available-amount": 20, ...}]`.""" + params = {**PRICE_PARAMS, "depth": depth or self.depth, "currency": self.currency} + body = self._get(f"/events/{event_id}/markets/{market_id}/runners/{runner_id}/prices", params) or {} + return list(body.get("prices") or []) + + def _closed_market(self, event_id: str, market_id: str) -> tuple[dict[str, Any], dict[str, Any]] | None: + """A market Matchbook no longer lists under its event (it closed), with + its runners in every state, and the event.""" + try: + event = self._get(f"/events/{event_id}") or {} + market = self._get(f"/events/{event_id}/markets/{market_id}", self._price_params()) or {} + except MarketNotFound: + return None + if not market.get("id"): + return None + if not market.get("runners"): + body = self._get(f"/events/{event_id}/markets/{market_id}/runners", { + "states": RUNNER_STATES, "include-withdrawn": "true", **self._price_params(), + }) or {} + market = {**market, "runners": body.get("runners") or []} + return event, market + + def _market(self, event_id: str, market_id: str) -> tuple[dict[str, Any], dict[str, Any]] | None: + """(event, market) for one Matchbook market, open or closed.""" + event = self.fetch_matchbook_events([event_id]).get(event_id) + market = next((m for m in (event or {}).get("markets") or [] if str(m.get("id")) == market_id), None) + if event is not None and market is not None: + return event, market + return self._closed_market(event_id, market_id) + + # -- catalog ------------------------------------------------------------ + + def _sport_ids(self, names_or_ids: list[str]) -> list[str]: + """Sport ids, resolving names ("Soccer", "horse racing") by asking the + venue. A name it does not have is refused rather than ignored.""" + named = [n for n in names_or_ids if not n.isdigit()] + if not named: + return names_or_ids + by_name = {str(s["name"]).lower(): str(s["id"]) for s in self.fetch_sports() if s.get("name")} + unknown = [n for n in named if n.lower() not in by_name] + if unknown: + raise BadRequest(f"{VENUE}: no sport named {', '.join(unknown)}") + return [n if n.isdigit() else by_name[n.lower()] for n in names_or_ids] + + def _filters(self, sport: Any, tag: Any) -> dict[str, Any]: + def listed(value: Any) -> list[str]: + return [str(v) for v in ([value] if isinstance(value, (str, int)) else value)] + + params: dict[str, Any] = {} + if sport: + params["sport-ids"] = ",".join(self._sport_ids(listed(sport))) + if tag: + params["tag-url-names"] = ",".join(listed(tag)) + return params + + def _walk(self, filters: dict[str, Any], cursor: str | None) -> Iterator[tuple[dict[str, Any], str, int, int, int]]: + """Open events in start-time order from `cursor`, each with its place: + (event, anchor, position from the anchor, first market, first runner). + + Matchbook pages its listing by offset. Offsets here count from an + anchor, a start time in whole seconds, since `after` includes the + events starting at it: each event is placed from its own start where + the page shows every event before it at that start, so a change to + the catalog earlier in the listing (an in-play match ending) cannot + shift the place, and dozens of fixtures kicking off at the same second + still page through.""" + anchor, position, market_skip, runner_skip = _cursor(cursor) + for _ in range(MAX_PAGES): + params = {**filters, "states": OPEN_STATES, "per-page": EVENT_PAGE, "offset": position, + "after": anchor, **self._price_params()} + body = self._get("/events", params) or {} + rows = [e for e in body.get("events") or [] if isinstance(e, dict) and e.get("id")] + first_start = str(start_s(rows[0])) if rows else None + at: dict[str, int] = {} + for index, event in enumerate(rows): + start = str(start_s(event)) + before = at.get(start, 0) + at[start] = before + 1 + if start == anchor or (position and start == first_start): + # Events at this start may sit before the page: count from the anchor. + place = (anchor or "", position + index) + else: + place = (start, before) + first = index == 0 + yield event, place[0], place[1], market_skip if first else 0, runner_skip if first else 0 + if len(rows) < EVENT_PAGE: + return + last = str(start_s(rows[-1])) + if last != anchor and (last != first_start or not position): + anchor, position = last, at[last] + else: + # The page is all one start that may have begun before it: keep counting. + position += len(rows) + market_skip = runner_skip = 0 + + def _markets_of(self, event: dict[str, Any], market_type: Any) -> list[dict[str, Any]]: + types = {market_type} if isinstance(market_type, str) else set(market_type or []) + return [m for m in event.get("markets") or [] if isinstance(m, dict) and m.get("id") + and (not types or m.get("market-type") in types)] + + def _listing_args(self, status: str, query: str | None, sort: str | None) -> None: + check_status(status) + if status != "open": + raise NotSupported(f"{VENUE}: Matchbook lists the markets of open events only; read a closed one by id") + if query: + raise NotSupported(f"{VENUE}: Matchbook has no text search; filter by sport= or tag=") + check_sort(sort, venue=VENUE, supported=False) + + def fetch_markets( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", sort: str | None = None, sport: str | list[str] | None = None, + tag: str | list[str] | None = None, market_type: str | list[str] | None = None, + ) -> Page[Market]: + """One page of runners (each a market), events soonest first, each + event's markets and runners in Matchbook's order, with their prices. + `sport` takes ids or names ("Soccer"), `tag` Matchbook's tag url + names (`england-premier-league`), `market_type` its market types + (`one_x_two`, `total`). Withdrawn runners are left out. Only open + events are listed; `fetch_market` reads a closed runner by id.""" + self._listing_args(status, query, sort) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + picked: list[Market] = [] + for event, anchor, position, first_market, first_runner in self._walk(self._filters(sport, tag), cursor): + markets = self._markets_of(event, market_type) + for offset, market in enumerate(markets[first_market:], start=first_market): + runners = [r for r in market.get("runners") or [] if not r.get("withdrawn")] + skip = first_runner if offset == first_market else 0 + rows = runners[skip:] + room = wanted - len(picked) + picked += [normalize_market(event, market, r, currency=self.currency) for r in rows[:room]] + if len(rows) > room: + return Page(picked, next_cursor=f"{anchor}|{position}|{offset}|{skip + room}") + if len(picked) >= wanted: + following = offset + 1 < len(markets) + cursor_ = f"{anchor}|{position}|{offset + 1}|0" if following else f"{anchor}|{position + 1}|0|0" + return Page(picked, next_cursor=cursor_) + return Page(picked, next_cursor=None) + + def fetch_events( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", sport: str | list[str] | None = None, tag: str | list[str] | None = None, + market_type: str | list[str] | None = None, + ) -> Page[Event]: + """One page of Matchbook markets (each a Synpath event) with their + runners priced. Filters as `fetch_markets`.""" + self._listing_args(status, query, None) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + picked: list[Event] = [] + for event, anchor, position, first_market, _ in self._walk(self._filters(sport, tag), cursor): + markets = self._markets_of(event, market_type)[first_market:] + room = wanted - len(picked) + picked += [normalize_event(event, market, currency=self.currency) for market in markets[:room]] + if len(markets) > room: + return Page(picked, next_cursor=f"{anchor}|{position}|{first_market + room}|0") + if len(picked) >= wanted: + return Page(picked, next_cursor=f"{anchor}|{position + 1}|0|0") + return Page(picked, next_cursor=None) + + def fetch_event(self, event_id: str) -> Event: + """One Matchbook market with its runners, by + `matchbook:-`, open or closed.""" + event_native, market_native = parse_market_id(self.native(event_id)) + found = self._market(event_native, market_native) + if found is None: + raise MarketNotFound(f"{VENUE}: no market {market_native_id(event_native, market_native)}") + return normalize_event(found[0], found[1], currency=self.currency) + + def fetch_market(self, market_id: str) -> Market: + """One runner by id, open or closed.""" + found = self.fetch_markets_by_ids([market_id]) + if not found: + raise MarketNotFound(f"{VENUE}: no runner {self.native(market_id)}") + return found[0] + + def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: + """Runners in the order asked for: twenty Matchbook events a request, + with every market and price, then one read per closed market. Ids + Matchbook does not know are left out.""" + wanted = list(dict.fromkeys(parse_runner_id(self.native(m)) for m in market_ids)) + events = self.fetch_matchbook_events([e for e, _, _ in wanted]) + closed: dict[tuple[str, str], tuple[dict[str, Any], dict[str, Any]] | None] = {} + out: list[Market] = [] + for event_id, market_id, runner_id in wanted: + event = events.get(event_id) + market = next((m for m in (event or {}).get("markets") or [] if str(m.get("id")) == market_id), None) + if market is None: + if event_id not in events: + continue + if (event_id, market_id) not in closed: + closed[(event_id, market_id)] = self._closed_market(event_id, market_id) + found = closed[(event_id, market_id)] + if found is None: + continue + event, market = found + runner = next((r for r in market.get("runners") or [] if str(r.get("id")) == runner_id), None) + if runner is not None: + out.append(normalize_market(event, market, runner, currency=self.currency)) + return out + + # -- market data -------------------------------------------------------- + + def fetch_order_book(self, market_id: str, *, side: BookSide = "yes", depth: int | None = None) -> OrderBook: + """A runner's prices on YES, `depth` levels a side (the adapter's + `depth`, three by default), from its prices alone: one small read.""" + event_id, market, runner = parse_runner_id(self.native(market_id)) + try: + prices = self.fetch_runner_prices(event_id, market, runner, depth=depth) + except MarketNotFound: + raise MarketNotFound(f"{VENUE}: no runner {self.native(market_id)}") from None + synpath_id = self.qualify(runner_native_id(event_id, market, runner)) + return normalize_order_book(prices, market_id=synpath_id, side=side, depth=depth, currency=self.currency) + + def fetch_order_books( + self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None, + ) -> dict[str, OrderBook]: + """Books for many runners, twenty Matchbook events a request. A + `-` pair (an event) gives every runner in it. + Runners Matchbook does not list (closed markets) are left out.""" + natives = [self.native(m) for m in dict.fromkeys(market_ids)] + events = self.fetch_matchbook_events([_parts(n)[0] for n in natives], depth=depth) + out: dict[str, OrderBook] = {} + for native in natives: + parts = _parts(native) + if len(parts) not in (2, 3): + raise BadRequest(f"{VENUE}: {native!r} is not a runner or market id") + event = events.get(parts[0]) or {} + market = next((m for m in event.get("markets") or [] if str(m.get("id")) == parts[1]), None) + if market is None: + continue + for runner in market.get("runners") or []: + if len(parts) == 3 and str(runner.get("id")) != parts[2]: + continue + synpath_id = self.qualify(runner_native_id(parts[0], parts[1], runner["id"])) + status, _ = status_of(event.get("status"), market.get("status"), runner.get("status"), + bool(runner.get("withdrawn"))) + out[synpath_id] = normalize_order_book( + runner.get("prices"), market_id=synpath_id, side=side, depth=depth, currency=self.currency, + info={"market_status": market.get("status"), "runner_status": runner.get("status"), + "status": status, "in_play": bool(market.get("live")), + "last_price_update": iso_ms(runner.get("last-price-update-time"))}, + ) + return out + + def fetch_trades( + self, market_id: str, *, since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> list: + raise NotSupported( + f"{VENUE}: Matchbook publishes no individual trades, only the volume matched per runner " + f"(in a market's stats)" + ) + + # -- reference ---------------------------------------------------------- + + def fetch_fee_schedule(self, market_id: str) -> FeeSchedule: + """Commission on net winnings per Matchbook market. Matchbook publishes + no per-market rate, so this reads nothing: the rate is the adapter's + `commission` (2%, the UK and Ireland rate). A runner id or a market + pair both name the market.""" + event_id, market = parse_market_id(self.native(market_id)) + return fee_schedule_of(market_native_id(event_id, market), self.commission) + + def close(self) -> None: + self.http.close() + if self.session is not None: + self.session.close() + + +def _cursor(cursor: str | None) -> tuple[str | None, int, int, int]: + """`anchor|position|market|runner`: the start second the walk resumes from + (empty for the start), the event's position from it, and the first market + and runner of that event to read.""" + if not cursor: + return None, 0, 0, 0 + try: + anchor, position, market, runner = cursor.split("|") + if anchor and not anchor.isdigit(): + raise ValueError + return anchor or None, max(0, int(position)), max(0, int(market)), max(0, int(runner)) + except ValueError: + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") from None + diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 6743665..4a6b254 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -35,7 +35,8 @@ log = logging.getLogger("synpath.serve") -VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet") +VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", + "matchbook") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index aad1025..3b3672f 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -81,12 +81,16 @@ def __getattr__(name: str): if name == "SxBetTrading": from .sx_bet import SxBetTrading return SxBetTrading + if name == "MatchbookTrading": + from .matchbook import MatchbookTrading + return MatchbookTrading raise AttributeError(name) __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "MatchbookTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index ab2f240..050b4b3 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -45,6 +45,11 @@ def doctor(dotenv: str | None) -> int: "SMARKETS_PASSWORD for undelayed quotes, or run synpath init. Betting needs Smarkets' " "approved API access (£150 one-off), not supported yet")) continue + if creds is None and venue == "matchbook": + # Market data is public; the login is for betting. + rows.append((venue, "not configured", "market data needs no login; set MATCHBOOK_USERNAME / " + "MATCHBOOK_PASSWORD to bet, or run synpath init")) + continue if creds is None and venue in market_data: stage = "testnet" if market_data[venue].testnet else "mainnet" private = ENV_NAMES[venue][0] @@ -94,6 +99,9 @@ def doctor(dotenv: str | None) -> int: elif venue == "smarkets": stage = "login" identity = f"user {creds.username}: undelayed quotes; betting needs approved API access, not supported yet" + elif venue == "matchbook": + stage = "login" + identity = f"user {creds.username}" elif getattr(creds, "api_key", None): identity = f"api key {creds.api_key[:8]}…" else: diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..38c45d0 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -45,6 +45,8 @@ (certificate login) and BETFAIR_JURISDICTION Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market (market data) data needs no login; one gives undelayed quotes + Matchbook MATCHBOOK_USERNAME, MATCHBOOK_PASSWORD: betting logs in; + market data needs no login """ from __future__ import annotations @@ -357,10 +359,26 @@ def secrets(self) -> list[str]: return [self.password] +@dataclass(frozen=True, repr=False) +class MatchbookCredentials: + """Matchbook: the account's login. Betting and the account's reads (offers, + bets, balance) log in with it; market data needs none. An account with + two-factor login cannot log in from here (pass a session token instead).""" + + username: str + password: str = field(repr=False) + _public = ("username",) + __repr__ = _redacted_repr + + @property + def secrets(self) -> list[str]: + return [self.password] + + Credentials = ( KalshiCredentials | PolymarketCredentials | PolymarketUSCredentials | PolymarketUSExchangeCredentials | OpinionCredentials | HyperliquidCredentials | PredictFunCredentials | LimitlessCredentials | SxBetCredentials - | BetfairCredentials | SmarketsCredentials + | BetfairCredentials | SmarketsCredentials | MatchbookCredentials ) ENV_NAMES: dict[str, tuple[str, ...]] = { @@ -389,6 +407,7 @@ def secrets(self) -> list[str]: "BETFAIR_JURISDICTION", ), "smarkets": ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"), + "matchbook": ("MATCHBOOK_USERNAME", "MATCHBOOK_PASSWORD"), } """What each venue reads. The first entry is the one whose absence means "not configured"; the rest are optional or have defaults.""" @@ -685,6 +704,15 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: return SmarketsCredentials(username=username, password=password) +def load_matchbook(env: Mapping[str, str]) -> MatchbookCredentials | None: + username, password = env.get("MATCHBOOK_USERNAME"), env.get("MATCHBOOK_PASSWORD") + if not username and not password: + return None + if not username or not password: + raise CredentialsMissing("a Matchbook login needs both MATCHBOOK_USERNAME and MATCHBOOK_PASSWORD") + return MatchbookCredentials(username=username, password=password) + + LOADERS = { "kalshi": load_kalshi, "polymarket": load_polymarket, @@ -697,6 +725,7 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: "sx_bet": load_sx_bet, "betfair": load_betfair, "smarkets": load_smarkets, + "matchbook": load_matchbook, } diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..4826047 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -278,6 +278,14 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} +def ask_matchbook(p: Prompter) -> dict[str, str]: + p.say("\nMatchbook: your Matchbook login, for betting. Market data needs no login. An account with") + p.say("two-factor login cannot log in from here. The password is typed hidden.") + username = p.value("Matchbook username", check=_required("The username")) + password = p.value("Matchbook password (typed hidden)", secret=True, check=_required("The password")) + return {"MATCHBOOK_USERNAME": username, "MATCHBOOK_PASSWORD": password} + + VENUES: list[tuple[str, tuple[str, ...], Callable[[Prompter], dict[str, str]]]] = [ ("Kalshi", ("KALSHI_KEY_ID",), ask_kalshi), ("Polymarket", ("POLYMARKET_PRIVATE_KEY",), ask_polymarket), @@ -289,6 +297,7 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), + ("Matchbook", ("MATCHBOOK_USERNAME",), ask_matchbook), ] """Each venue, the settings any of which means it is set up already (an API key alone is, for market data), and the questions.""" diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 509a8fd..64ce59a 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -66,6 +66,12 @@ # £0.05. "smarkets": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.0001"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("0.05")}, + # Prices are decimal odds on Matchbook's ladder (1.01 to 1000, the same + # bands as Betfair's), so the tick here is the finest and the trading + # adapter snaps each order onto the ladder in odds. Stakes are in the + # account's currency to the penny; Matchbook publishes no minimum. + "matchbook": {"default_tick": Decimal("0.00001"), "min_quantity": Decimal("0.01"), "quantity_step": Decimal("0.0001"), + "whole": False}, } diff --git a/synpath/trading/matchbook.py b/synpath/trading/matchbook.py new file mode 100644 index 0000000..d6f6a79 --- /dev/null +++ b/synpath/trading/matchbook.py @@ -0,0 +1,842 @@ +"""Matchbook order entry: offers placed, edited and cancelled, and the +account's offers, bets, positions and balance on the YES leg. + +Built to Matchbook's API reference (developers.matchbook.com); its records +follow the reference's examples. Every call carries the session token of an +account login (`MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD`); see +`synpath.matchbook.MatchbookSession`. + +**An order is an offer to back or lay one runner.** `buy` backs the runner +(takes its YES) at decimal odds 1/price; `sell` lays it (takes its NO) at the +same odds. Matchbook counts both in the backer's stake: an order for `n` +contracts stakes `n / odds`, rounded down to the penny, and a lay's liability +is that stake times `odds - 1`. Prices are snapped onto Matchbook's odds +ladder (1.01 to 1000) on the cheaper side: a buy at higher odds (a lower YES +price), a sell at lower odds (a higher YES price). + +**Limits rest; market orders take what is there.** Matchbook has no +immediate-or-cancel offer, so a market order, or an IOC limit, is an offer at +the price given as the worst accepted whose unmatched rest is cancelled at +once (after the in-play delay when the market is in play). A limit is `GTC` +and rests until matched or cancelled; by default its unmatched part is +cancelled when the event goes in play (`params={"keep_in_play": True}` keeps +it). FOK, GTD and post-only orders are not offered. + +**No client order id.** Matchbook assigns the offer id and takes none from the +client; `client_order_id` is kept on the returned order only. After a timeout, +read the open offers on the runner to find what was placed. + +**Positions** net per runner on the YES leg from the account's current +matched bets: backs are YES contracts, lays NO contracts, a stake S at odds O +being S·O contracts either way. + +**Fees** are commission on the account's net winnings per market at +settlement (2% in the UK and Ireland, 4% elsewhere by Matchbook's terms); +API and commercial accounts can be on volume commission instead, which each +offer names (`commission-type`, `originator-commission-rate`). + +The *heartbeat* (`heartbeat(seconds)`) is a dead man's switch: unless renewed +in time, Matchbook cancels every open offer on the account. +""" +from __future__ import annotations + +import asyncio +import bisect +import time +from decimal import ROUND_DOWN, Decimal +from typing import Any + +from .. import ids +from ..base import AsyncHttpClient, Capability, RateLimiter +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound +from ..matchbook import ( + API_URL, EDGE, ODDS_LADDER, MatchbookSession, error_messages, iso_ms, market_native_id, parse_market_id, + parse_runner_id, runner_native_id, +) +from ..smarkets import user_agent +from ..types import Page +from .base import TradingExchange +from .credentials import MatchbookCredentials +from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected +from .limiter import BudgetLimiter, Priority +from .types import ( + Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, + Position, PositionSide, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "matchbook" +ONE = Decimal("1") +PRICE_PLACES = Decimal("0.0000000001") +PENNY = Decimal("0.01") + +OFFERS_PER_REQUEST = 50 +"""Offers one submit or cancel request carries. Matchbook documents no cap; +kept modest.""" + +PER_PAGE = 500 +"""Offers or bets read per request when listing.""" + +MAX_PAGES = 50 +"""Most pages one listing call reads.""" + +DELAY_GRACE_S = 0.5 +"""Seconds past an in-play delay before an immediate order's rest is cancelled.""" + +REPORTS_LIMITER = RateLimiter(40 / 60, burst=2) +"""The reports group (current and settled bets) allows 40 requests a minute.""" + +STATUS: dict[str, OrderStatus] = { + "open": OrderStatus.OPEN, + "edited": OrderStatus.OPEN, + "matched": OrderStatus.CLOSED, + "cancelled": OrderStatus.CANCELED, + "flushed": OrderStatus.CANCELED, + "failed": OrderStatus.REJECTED, + "delayed": OrderStatus.PENDING, +} +"""An offer's state by Matchbook's status. An edited offer is still resting +with its new values; a flushed one was taken off the market by Matchbook (a +withdrawn runner, a goal); a delayed one waits out an in-play delay before it +enters the market.""" + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return Decimal("0") + return Decimal(str(value)) + + +def yes_price(odds: Any) -> Decimal: + """Decimal odds as the YES price 1/O, to ten places.""" + return (ONE / D(odds)).quantize(PRICE_PLACES) + + +def side_of(native: Any) -> Side: + """`back` (or `win`) takes YES; `lay` (or `lose`) takes NO.""" + return Side.BUY if str(native or "").lower() in ("back", "win") else Side.SELL + + +def odds_for(side: Side, price: Any) -> Decimal: + """The ladder odds for an order at this YES price, on the cheaper side: a + buy at the first odds at or above 1/price, a sell at the last at or below.""" + p = D(price) + if not 0 < p < 1: + raise InvalidOrder(f"{VENUE}: a price is a YES probability strictly between 0 and 1, not {price}") + # To a millionth of a point of odds, so a price such as 0.3333333333 + # meant as odds 3 is not pushed to the next tick by its last digit. + raw = (ONE / p).quantize(Decimal("0.000001")) + if side == Side.BUY: + index = bisect.bisect_left(ODDS_LADDER, raw) + if index >= len(ODDS_LADDER): + raise InvalidOrder(f"{VENUE}: {price} is below the ladder's lowest YES price (odds 1000)") + return ODDS_LADDER[index] + index = bisect.bisect_right(ODDS_LADDER, raw) - 1 + if index < 0: + raise InvalidOrder(f"{VENUE}: {price} is above the ladder's highest YES price (odds 1.01)") + return ODDS_LADDER[index] + + +def stake_for(quantity: Any, odds: Decimal) -> Decimal: + """The backer's stake for `quantity` contracts at `odds`, down to the penny.""" + stake = (D(quantity) / odds).quantize(PENNY, rounding=ROUND_DOWN) + if stake <= 0: + raise InvalidOrder(f"{VENUE}: {quantity} contracts at odds {odds} is less than a penny of stake") + return stake + + +def _number(value: Decimal) -> float | int: + """A Decimal as the JSON number Matchbook reads.""" + return int(value) if value == value.to_integral_value() else float(value) + + +def _offer_market(row: dict[str, Any]) -> str: + return ids.qualify(VENUE, runner_native_id(row.get("event-id"), row.get("market-id"), row.get("runner-id"))) + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def order_of_offer(row: dict[str, Any], *, account: Account | None = None) -> Order: + """One offer as Matchbook reports it (submit, get, cancel, edit). Stakes + become contracts at the offer's own odds.""" + odds = D(row.get("decimal-odds") or row.get("odds")) + stake, remaining = D(row.get("stake")), D(row.get("remaining")) + quantity, left = stake * odds, remaining * odds + native = str(row.get("status") or "") + status = STATUS.get(native, OrderStatus.PENDING) + if status == OrderStatus.OPEN and remaining <= 0 and stake > 0: + status = OrderStatus.CLOSED + filled = quantity if status == OrderStatus.CLOSED else max(Decimal("0"), quantity - left) + if status in (OrderStatus.PENDING, OrderStatus.REJECTED): + filled = Decimal("0") + bets = [b for b in row.get("matched-bets") or [] if isinstance(b, dict)] + bet_stake = sum((D(b.get("stake")) for b in bets), Decimal("0")) + bet_contracts = sum((D(b.get("stake")) * D(b.get("decimal-odds") or b.get("odds")) for b in bets), Decimal("0")) + average = (bet_stake / bet_contracts).quantize(PRICE_PLACES) if bet_contracts else None + fee = sum((D(b.get("commission")) for b in bets), Decimal("0")) if bets else None + return Order( + id=str(row.get("id") or ""), + venue=VENUE, + account=account, + market_id=_offer_market(row), + side=side_of(row.get("side")), + type=OrderType.LIMIT, + time_in_force=TimeInForce.GTC, + status=status, + price=yes_price(odds) if odds else None, + quantity=quantity, + filled=filled, + remaining=left if status in (OrderStatus.OPEN, OrderStatus.PENDING) else Decimal("0"), + average_price=average, + fee=fee, + fee_currency=row.get("currency"), + created_at=iso_ms(row.get("created-at")), + info=row, + ) + + +def fills_of_offer(row: dict[str, Any], *, account: Account | None = None) -> list[Fill]: + """The matched bets on one offer, each a fill: contracts are its stake + times its odds, the price 1/odds on the YES leg.""" + out = [] + for bet in row.get("matched-bets") or []: + if not isinstance(bet, dict): + continue + odds = D(bet.get("decimal-odds") or bet.get("odds")) + if not odds: + continue + out.append(Fill( + id=str(bet.get("id") or ""), + order_id=str(bet.get("offer-id") or row.get("id") or ""), + venue=VENUE, + account=account, + market_id=_offer_market(row), + side=side_of(row.get("side")), + price=yes_price(odds), + quantity=D(bet.get("stake")) * odds, + fee=D(bet["commission"]) if bet.get("commission") is not None else None, + fee_currency=bet.get("currency") or row.get("currency"), + liquidity=Liquidity.UNKNOWN, + timestamp=iso_ms(bet.get("created-at")) or 0, + info=bet, + )) + return out + + +def positions_of_bets(body: dict[str, Any], *, account: Account | None = None) -> list[Position]: + """Current matched bets (`/reports/v2/bets/current`) netted per runner on + the YES leg: backs are YES contracts, lays NO contracts.""" + rows: dict[str, dict[str, Any]] = {} + for market in body.get("markets") or []: + for selection in market.get("selections") or []: + synpath_id = ids.qualify(VENUE, runner_native_id(market.get("event-id"), market.get("id"), + selection.get("runner-id"))) + row = rows.setdefault(synpath_id, {"yes": Decimal("0"), "no": Decimal("0"), "stake": Decimal("0"), + "margin": Decimal("0"), "selections": [], "market": market}) + back = str(selection.get("side") or "").lower() in ("back", "win") + for bet in selection.get("bets") or []: + odds, stake = D(bet.get("odds")), D(bet.get("stake")) + row["yes" if back else "no"] += stake * odds + row["stake"] += stake + row["margin"] += D(bet.get("liability")) if bet.get("liability") is not None else ( + stake if back else stake * (odds - 1)) + row["selections"].append(selection) + out = [] + for synpath_id, row in rows.items(): + net = row["yes"] - row["no"] + traded = row["yes"] + row["no"] + out.append(Position( + venue=VENUE, + account=account, + market_id=synpath_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), + inventory_yes=row["yes"], + inventory_no=row["no"], + # The average YES price of every bet: stakes over contracts. + entry_price=(row["stake"] / traded).quantize(PRICE_PLACES) if traded else None, + margin=row["margin"], + info={"selections": row["selections"], + "market": {k: v for k, v in row["market"].items() if k != "selections"}}, + )) + return out + + +RESULTS = {("back", "win"): "yes", ("back", "lose"): "no", ("lay", "win"): "no", ("lay", "lose"): "yes"} +"""A settled bet's result, which is the bet's own (a lay that wins means the +runner lost), as the runner's YES result.""" + + +def settlements_of(body: dict[str, Any], *, account: Account | None = None) -> list[Settlement]: + """Settled bets (`/reports/v2/bets/settled`), one settlement per runner and + side the account bet.""" + out = [] + for market in body.get("markets") or []: + for selection in market.get("selections") or []: + side = str(selection.get("side") or "").lower() + bets = [b for b in selection.get("bets") or [] if isinstance(b, dict)] + results = {RESULTS.get((side, str(b.get("result") or "").lower())) for b in bets} - {None} + result = results.pop() if len(results) == 1 else None + quantity = sum((D(b.get("stake")) * D(b.get("odds")) for b in bets), Decimal("0")) + pnl = selection.get("net-profit-and-loss") + out.append(Settlement( + venue=VENUE, + account=account, + market_id=ids.qualify(VENUE, runner_native_id(market.get("event-id"), market.get("id"), + selection.get("runner-id"))), + held=PositionSide.LONG if side in ("back", "win") else PositionSide.SHORT, + result=result, + won=(D(pnl) > 0) if pnl is not None else None, + quantity=quantity, + cost=D(selection.get("stake")) if side in ("back", "win") else None, + pnl=D(pnl) if pnl is not None else None, + timestamp=iso_ms(market.get("settled-time")), + info={"selection": selection, "market": {k: v for k, v in market.items() if k != "selections"}}, + )) + return out + + +def reason_of(body: Any, fallback: str) -> str: + return error_messages(body) or fallback + + +def error_of(exc: ExchangeError) -> ExchangeError: + """Matchbook's refusal of an offer as a typed error, by its message.""" + text = reason_of(exc.body, str(exc)) + lower = text.lower() + if "fund" in lower or "balance" in lower or "exposure" in lower: + return InsufficientFunds(f"{VENUE}: {text}", body=exc.body) + if "suspended" in lower or "closed" in lower: + return MarketHalted(f"{VENUE}: {text}", body=exc.body, status=exc.status) + if "odds" in lower or "stake" in lower or "runner" in lower: + return InvalidOrder(f"{VENUE}: {text}") + return OrderRejected(f"{VENUE}: {text}", reason=lower or None, + info=exc.body if isinstance(exc.body, dict) else {}, body=exc.body) + + +def _rejected(row: dict[str, Any]) -> OrderRejected: + text = reason_of(row, "the offer failed") + return OrderRejected(f"{VENUE}: offer refused: {text}", reason="failed", info=row) + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def immediate(request: OrderRequest) -> bool: + """Whether the order takes what is there and leaves nothing resting.""" + return request.type == OrderType.MARKET or request.time_in_force == TimeInForce.IOC + + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.time_in_force in (TimeInForce.FOK, TimeInForce.GTD, TimeInForce.DAY): + raise InvalidOrder(f"{VENUE}: Matchbook offers rest until matched or cancelled; gtc or ioc only") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") + if request.post_only: + raise InvalidOrder(f"{VENUE}: Matchbook has no post-only offer") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: Matchbook nets every bet on a runner; there is no reduce-only offer") + + +def build_offer(request: OrderRequest) -> dict[str, Any]: + """One request as a Matchbook offer, before anything is sent.""" + check_request(request) + _, _, runner_id = parse_runner_id(ids.native(VENUE, request.market_id)) + odds = odds_for(request.side, request.price) + stake = stake_for(request.quantity, odds) + return { + "runner-id": int(runner_id), + "side": "back" if request.side == Side.BUY else "lay", + "odds": _number(odds), + "stake": _number(stake), + "keep-in-play": bool(request.params.get("keep_in_play", False)), + } + + +def order_after_placing(request: OrderRequest, row: dict[str, Any], *, account: Account) -> Order: + """The order from Matchbook's answer to a submit, with what the request + carried that Matchbook does not keep.""" + order = order_of_offer(row, account=request.account or account) + return order.model_copy(update={ + "client_order_id": request.client_order_id, "type": request.type, + "time_in_force": request.time_in_force, "book": request.book, "trader": request.trader, + "tags": request.tags, + }) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class MatchbookTrading(TradingExchange): + """Matchbook order entry. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.matchbook import MatchbookTrading + + async with MatchbookTrading(require("matchbook", load_credentials())) as mb: + print(await mb.fetch_balance()) + ``` + """ + + id = VENUE + name = "Matchbook" + has: dict[str, Capability] = { + "create_order": True, + # One request, one answer per offer; not atomic. + "create_orders": True, + "cancel_order": True, + "cancel_orders": True, + "cancel_all_orders": True, + # Odds and remaining stake, in place. + "edit_order": True, + "fetch_order": True, + "fetch_open_orders": True, + # Offers on markets not yet settled only. + "fetch_orders": "partial", + "fetch_my_trades": "partial", + "fetch_positions": True, + "fetch_balance": True, + "fetch_settlements": True, + "fetch_queue_position": False, + "fetch_fee_estimate": True, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: MatchbookCredentials | None = None, + *, + session: MatchbookSession | None = None, + session_token: str | None = None, + account_name: str = "default", + url: str = API_URL, + catalog: Any = None, + limiter: BudgetLimiter | None = None, + reports_limiter: RateLimiter | None | str = "shared", + timeout: float = 30.0, + client: Any = None, + session_client: Any = None, + ): + """`credentials` log in on the first call; `session_token` reuses a + login made elsewhere (an account with two-factor login needs that).""" + import httpx + + if session is None: + if credentials is None and not session_token: + from .errors import CredentialsMissing + + raise CredentialsMissing(f"{VENUE}: set MATCHBOOK_USERNAME and MATCHBOOK_PASSWORD, or pass session_token=") + session = MatchbookSession( + username=credentials.username if credentials else None, + password=credentials.password if credentials else None, + session_token=session_token, client=session_client, timeout=timeout, base_url=url, + ) + self.credentials = credentials + self.session = session + self.account = Account(venue=VENUE, name=account_name) + # Matchbook's betting writes allow 3,000 a minute and its account reads 300. + self.limiter = limiter or BudgetLimiter(read_per_second=4, write_per_second=20) + self.reports_limiter = REPORTS_LIMITER if reports_limiter == "shared" else reports_limiter + self.url = url + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + shared.headers["User-Agent"] = user_agent() + shared.headers["Accept"] = "application/json" + self.http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE) + self._own_catalog = catalog is None + if catalog is None: + from ..matchbook import Matchbook + + catalog = Matchbook(base_url=url) + self.catalog = catalog + """The read adapter: fee schedules.""" + self._currency: str | None = None + + # -- plumbing ------------------------------------------------------------- + + async def _call( + self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, reports: bool = False, + ) -> Any: + """One call with the session token, paced; a 401 logs in again once.""" + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + if reports and self.reports_limiter is not None: + await self.reports_limiter.acquire_async() + query = {k: v for k, v in (params or {}).items() if v is not None} + for attempt in range(2): + token = await asyncio.to_thread(self.session.token) + try: + return await self.http.request(method, f"{EDGE}{path}", params=query or None, json=body, + headers={"session-token": token}) + except AuthenticationError as exc: + if exc.status == 401 and attempt == 0: + self.session.invalidate() + continue + raise + except MarketNotFound as exc: + if "/offers" in path: + raise OrderNotFound(f"{VENUE}: {reason_of(exc.body, 'no such offer')}", body=exc.body, + status=exc.status) from None + raise + raise AssertionError("unreachable") # pragma: no cover + + async def currency(self) -> str: + """The account's currency, read once: every stake and balance is in it.""" + if self._currency is None: + account = self.session.account + if not account or not account.get("currency"): + account = await self._call("GET", "/account") or {} + self._currency = str(account.get("currency") or "") + return self._currency + + async def heartbeat(self, timeout_s: int) -> dict[str, Any]: + """Arm or renew the dead man's switch: unless this is called again + within `timeout_s` seconds, Matchbook cancels every open offer on the + account. Matchbook may set a longer timeout than asked (its answer's + `actual-timeout`). `0` turns it off.""" + if int(timeout_s) < 0: + raise BadRequest(f"{VENUE}: the heartbeat timeout is a number of seconds") + if int(timeout_s) == 0: + return await self._call("DELETE", "/v1/heartbeat", kind="write", priority=Priority.HIGH) or {} + return await self._call("POST", "/v1/heartbeat", body={"timeout": int(timeout_s)}, + kind="write", priority=Priority.HIGH) or {} + + # -- orders --------------------------------------------------------------- + + async def _submit(self, offers: list[dict[str, Any]]) -> list[dict[str, Any]]: + body = {"odds-type": "DECIMAL", "exchange-type": "back-lay", "offers": offers} + try: + raw = await self._call("POST", "/v2/offers", body=body, kind="write") or {} + except BadRequest as exc: + # A refused batch still answers offer by offer where it can. + rows = (exc.body or {}).get("offers") if isinstance(exc.body, dict) else None + if isinstance(rows, list) and len(rows) == len(offers): + return [dict(row, status=row.get("status") or "failed") for row in rows] + raise error_of(exc) from None + return list(raw.get("offers") or []) + + async def create_order(self, request: OrderRequest) -> Order: + """Place one offer. Matchbook answers with what matched on arrival; an + immediate order's unmatched rest is cancelled before this returns.""" + result = await self.create_orders([request]) + if isinstance(result[0], Exception): + raise result[0] + return result[0] + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many offers, fifty a request, one result each in the order asked + for. A refused offer comes back as its exception.""" + out: list[Order | Exception] = [] + for start in range(0, len(requests), OFFERS_PER_REQUEST): + chunk = requests[start:start + OFFERS_PER_REQUEST] + built: list[tuple[OrderRequest, dict[str, Any]] | Exception] = [] + for request in chunk: + try: + built.append((request, build_offer(request))) + except (InvalidOrder, BadRequest) as exc: + built.append(exc) + ready = [b for b in built if not isinstance(b, Exception)] + rows = iter(await self._submit([offer for _, offer in ready]) if ready else []) + for entry in built: + if isinstance(entry, Exception): + out.append(entry) + continue + request, _ = entry + row = next(rows, None) + if not isinstance(row, dict) or row.get("status") == "failed" or not row.get("id"): + out.append(_rejected(row if isinstance(row, dict) else {})) + continue + out.append(order_after_placing(request, row, account=self.account)) + return await self._finish_immediate(requests, out) + + async def _finish_immediate(self, requests: list[OrderRequest], placed: list[Order | Exception]) -> list[Order | Exception]: + """Cancel the unmatched rest of every market or IOC order, waiting out + an in-play delay first, so nothing of them is left resting.""" + to_cancel = [i for i, (request, order) in enumerate(zip(requests, placed)) + if immediate(request) and isinstance(order, Order) + and order.status in (OrderStatus.OPEN, OrderStatus.PENDING)] + if not to_cancel: + return placed + delay = max((float(placed[i].info.get("delay") or 0) for i in to_cancel # type: ignore[union-attr] + if placed[i].status == OrderStatus.PENDING), default=0.0) # type: ignore[union-attr] + if delay: + await asyncio.sleep(delay + DELAY_GRACE_S) + cancelled = await self.cancel_orders([placed[i].id for i in to_cancel]) # type: ignore[union-attr] + out = list(placed) + for i, result in zip(to_cancel, cancelled): + original = placed[i] + if isinstance(result, Order): + request = requests[i] + out[i] = result.model_copy(update={ + "client_order_id": request.client_order_id, "type": request.type, + "time_in_force": request.time_in_force, "book": request.book, "trader": request.trader, + "tags": request.tags, + }) + else: + out[i] = result if not isinstance(original, Order) else original + return out + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one offer. One that can no longer be cancelled (matched, + already cancelled) is read back as it stands.""" + try: + row = await self._call("DELETE", f"/v2/offers/{order_id}", kind="write", priority=Priority.HIGH) + except BadRequest as exc: + order = await self.fetch_order(order_id) + if order.is_terminal: + return order + raise OrderRejected(f"{VENUE}: cancel of {order_id} refused: {reason_of(exc.body, str(exc))}", + reason="cancel_refused", body=exc.body) from None + return order_of_offer(row or {}, account=self.account) + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel up to fifty offers a request. An offer not in Matchbook's + answer (no longer open) is read back as it stands.""" + cancelled: dict[str, dict[str, Any]] = {} + for start in range(0, len(order_ids), OFFERS_PER_REQUEST): + chunk = order_ids[start:start + OFFERS_PER_REQUEST] + raw = await self._call("DELETE", "/v2/offers", params={"offer-ids": ",".join(chunk)}, + kind="write", priority=Priority.HIGH) or {} + for row in raw.get("offers") or []: + if isinstance(row, dict) and row.get("id") is not None: + cancelled[str(row["id"])] = row + out: list[Order | Exception] = [] + for order_id in order_ids: + if str(order_id) in cancelled: + out.append(order_of_offer(cancelled[str(order_id)], account=self.account)) + continue + try: + out.append(await self.fetch_order(order_id)) + except OrderNotFound as exc: + out.append(exc) + return out + + def _scope(self, market_id: str | None) -> dict[str, Any]: + """Matchbook's filter for a runner, a market pair or an event id.""" + if not market_id: + return {} + parts = ids.native(VENUE, market_id).split("-") + if not all(part.isdigit() for part in parts) or len(parts) > 3: + raise BadRequest(f"{VENUE}: {market_id!r} is not a Matchbook runner, market or event id") + if len(parts) == 3: + return {"runner-ids": parts[2]} + if len(parts) == 2: + return {"market-ids": parts[1]} + return {"event-ids": parts[0]} + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int: + """Every open offer on a runner, a market (`-`) or the + account. Returns how many Matchbook cancelled.""" + raw = await self._call("DELETE", "/v2/offers", params=self._scope(market_id) or None, + kind="write", priority=Priority.HIGH) or {} + return len(raw.get("offers") or []) + + async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order: + """New odds or a new remaining stake on an unmatched offer, in place. + `quantity` is the order's new total, filled contracts included.""" + if request.time_in_force is not None or request.expires_at is not None: + raise InvalidOrder(f"{VENUE}: an offer's time in force cannot be edited; cancel and replace") + current = current or await self.fetch_order(request.order_id) + if request.price is None and request.quantity is None: + return current + info = current.info + current_odds = D(info.get("decimal-odds") or info.get("odds")) + current_stake = D(info.get("remaining")) + odds = odds_for(current.side, request.price) if request.price is not None else current_odds + left = (D(request.quantity) - current.filled) if request.quantity is not None else current_stake * current_odds + if left <= 0: + raise InvalidOrder(f"{VENUE}: the new quantity leaves nothing unmatched; cancel the offer instead") + body = { + "current-odds": _number(current_odds), "new-odds": _number(odds), + "current-stake": _number(current_stake), "new-stake": _number(stake_for(left, odds)), + } + try: + row = await self._call("PUT", f"/v2/offers/{request.order_id}", body=body, kind="write") + except BadRequest as exc: + edit = (exc.body or {}).get("offer-edit") if isinstance(exc.body, dict) else None + if isinstance(edit, dict) and edit.get("errors"): + raise InvalidOrder(f"{VENUE}: edit refused: {reason_of(edit, 'refused')}") from None + raise error_of(exc) from None + edit = (row or {}).get("offer-edit") or {} + if edit.get("errors"): + raise InvalidOrder(f"{VENUE}: edit refused: {reason_of(edit, 'refused')}") + order = order_of_offer(row or {}, account=self.account) + return order.model_copy(update={"client_order_id": current.client_order_id}) + + async def fetch_order(self, order_id: str) -> Order: + """One offer by id, in any state, while its market is unsettled.""" + row = await self._call("GET", f"/v2/offers/{order_id}") + if not isinstance(row, dict) or not row.get("id"): + raise OrderNotFound(f"{VENUE}: no offer {order_id}") + return order_of_offer(row, account=self.account) + + async def _offers(self, params: dict[str, Any], *, offset: int = 0, pages: int = MAX_PAGES) -> tuple[list[dict[str, Any]], int | None]: + """Offer rows from `offset`, page by page; and the next offset, when + more are left.""" + rows: list[dict[str, Any]] = [] + for _ in range(pages): + raw = await self._call("GET", "/v2/offers", params={**params, "offset": offset, "per-page": PER_PAGE}) or {} + page = [r for r in raw.get("offers") or [] if isinstance(r, dict)] + rows += page + offset += len(page) + if not page or offset >= int(raw.get("total") or 0): + return rows, None + return rows, offset + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Every resting offer, or every one on a runner or market.""" + rows, _ = await self._offers({"status": "open,edited", **self._scope(market_id)}) + return [order_of_offer(row, account=self.account) for row in rows] + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of offers on unsettled markets, in any state: `open`, + `closed` (fully matched) or `canceled` (cancelled or flushed). + `since` keeps offers changed since then.""" + native = {"open": "open,edited", "closed": "matched", "canceled": "cancelled,flushed"} + if status is not None and status not in native: + raise BadRequest(f"{VENUE}: status is open, closed or canceled") + params: dict[str, Any] = {"status": native.get(status or ""), **self._scope(market_id)} + if since is not None: + params["interval"] = max(1, int(time.time() - since / 1000)) + offset = _offset(cursor) + raw = await self._call("GET", "/v2/offers", params={**params, "offset": offset, + "per-page": min(limit or PER_PAGE, PER_PAGE)}) or {} + rows = [r for r in raw.get("offers") or [] if isinstance(r, dict)] + orders = [order_of_offer(row, account=self.account) for row in rows] + if status == "closed": + orders = [o for o in orders if o.status == OrderStatus.CLOSED] + following = offset + len(rows) + return Page(orders, next_cursor=str(following) if rows and following < int(raw.get("total") or 0) else None) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """The matched bets on this account's offers in unsettled markets, one + fill each; settled ones are in `fetch_settlements`.""" + if order_id: + fills = fills_of_offer((await self.fetch_order(order_id)).info, account=self.account) + next_cursor = None + else: + offset = _offset(cursor) + rows, following = await self._offers({"status": "matched", **self._scope(market_id)}, offset=offset, pages=1) + fills = [f for row in rows for f in fills_of_offer(row, account=self.account)] + next_cursor = str(following) if following is not None else None + if since is not None: + fills = [f for f in fills if f.timestamp >= since] + fills.sort(key=lambda f: f.timestamp, reverse=True) + return Page(fills[:limit] if limit else fills, next_cursor=next_cursor) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """Cash (`balance`), what bets hold (`exposure` and the commission + reserve, `locked`), and what is free to bet.""" + raw = await self._call("GET", "/account/balance") or {} + total = D(raw.get("balance")) + free = D(raw.get("free-funds")) + locked = abs(D(raw.get("exposure"))) + abs(D(raw.get("commission-reserve"))) + return Balance( + venue=VENUE, account=account or self.account, currency=await self.currency(), + total=total, available=free, locked=locked, buying_power=free, + timestamp=int(time.time() * 1000), info=raw, + ) + + async def _reports(self, path: str, params: dict[str, Any], *, offset: int = 0, pages: int = MAX_PAGES) -> tuple[dict[str, Any], int | None]: + """A bets report, its markets merged across pages.""" + merged: dict[str, Any] = {"markets": []} + for _ in range(pages): + raw = await self._call("GET", path, params={**params, "offset": offset, "per-page": PER_PAGE, + "odds-type": "DECIMAL"}, reports=True) or {} + page = [m for m in raw.get("markets") or [] if isinstance(m, dict)] + merged = {**raw, "markets": merged["markets"] + page} + offset += len(page) + if not page or offset >= int(raw.get("total") or 0): + return merged, None + return merged, offset + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """The account's current matched bets netted per runner on the YES leg. + `market_id` is a runner, `event_id` a Matchbook market (`-`).""" + params: dict[str, Any] = {} + wanted = None + if market_id: + _, market, _ = parse_runner_id(ids.native(VENUE, market_id)) + params["market-ids"] = market + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + elif event_id: + _, market = parse_market_id(ids.native(VENUE, event_id)) + params["market-ids"] = market + body, _ = await self._reports("/reports/v2/bets/current", params) + positions = positions_of_bets(body, account=self.account) + return [p for p in positions if p.market_id == wanted] if wanted else positions + + async def fetch_settlements( + self, *, market_id: str | None = None, since: int | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Settlement]: + """One page of settled bets, one settlement per runner and side, newest + markets first as Matchbook lists them.""" + params: dict[str, Any] = {} + if market_id: + params["market-ids"] = parse_market_id(ids.native(VENUE, market_id))[1] + if since is not None: + from ..types import iso + + params["after"] = iso(since) + body, following = await self._reports("/reports/v2/bets/settled", params, offset=_offset(cursor), pages=1) + settlements = settlements_of(body, account=self.account) + if market_id and len(ids.native(VENUE, market_id).split("-")) == 3: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + settlements = [s for s in settlements if s.market_id == wanted] + return Page(settlements[:limit] if limit else settlements, + next_cursor=str(following) if following is not None else None) + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The commission on `quantity` contracts at the YES `price` if the bet + wins: a share of its profit. A `sell` lays the runner, which wins + `price` a contract when the runner loses. Makers pay the same.""" + schedule = await asyncio.to_thread(self.catalog.fetch_fee_schedule, market_id) + paid = D(price) if side == Side.BUY else ONE - D(price) + fee = schedule.estimate(float(paid), float(quantity), taker=True) + event, market = parse_market_id(ids.native(VENUE, market_id)) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, ids.native(VENUE, market_id)), side=side, + price=D(price), quantity=D(quantity), + taker_fee=D(fee) if fee is not None else None, maker_fee=D(fee) if fee is not None else None, + currency=await self.currency(), + info={"schedule": schedule.model_dump(), "market": market_native_id(event, market), + "charged": "at settlement, on net winnings in the market"}, + ) + + async def close(self) -> None: + await self.http.close() + self.session.close() + if self._own_catalog: + self.catalog.close() + + +def _offset(cursor: str | None) -> int: + if not cursor: + return 0 + if not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + return int(cursor) + + +__all__ = ["MatchbookTrading", "order_of_offer", "fills_of_offer", "positions_of_bets", "settlements_of", "build_offer"] diff --git a/synpath/types.py b/synpath/types.py index 3233e1d..9648f82 100644 --- a/synpath/types.py +++ b/synpath/types.py @@ -585,7 +585,7 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo * `profit_on_win` (SX Bet) — `rate * (1 - P) * C`, a share of the profit, charged at settlement and only if the bet wins. The estimate is that charge, so on a bet that loses it is an upper bound. - * `net_market_winnings` (Betfair, Smarkets) — `rate * (1 - P) * C`, the + * `net_market_winnings` (Betfair, Smarkets, Matchbook) — `rate * (1 - P) * C`, the commission on what this fill wins if it wins, at the market's base rate (`taker_rate` and `maker_rate` alike: Betfair charges both sides). Betfair cannot be priced per fill exactly: it charges the @@ -596,7 +596,8 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo upper bound when other bets in the market lose and leaves the Expert Fee out. Smarkets charges the same way (2% on its standard tier, no - Expert Fee), on net winnings per Smarkets market. + Expert Fee), on net winnings per Smarkets market, and Matchbook + too (2% in the UK and Ireland, 4% elsewhere), per Matchbook market. """ rate = self.taker_rate if taker else self.maker_rate if self.fee_type in KALSHI_MAKER_SHARE and self.multiplier is not None: diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index 6260aee..ceb25bf 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -56,6 +56,9 @@ def __getattr__(name: str): if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream + if name == "MatchbookMarketStream": + from .matchbook import MatchbookMarketStream + return MatchbookMarketStream if name in ("HyperliquidMarketStream", "HyperliquidUserStream"): from . import hyperliquid return getattr(hyperliquid, name) @@ -75,5 +78,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "MatchbookMarketStream", ] diff --git a/synpath/ws/matchbook.py b/synpath/ws/matchbook.py new file mode 100644 index 0000000..0c96c08 --- /dev/null +++ b/synpath/ws/matchbook.py @@ -0,0 +1,397 @@ +"""Matchbook market data as a stream, polled from its public REST events. + +**Not a socket.** Matchbook has no push API: no WebSocket, no SSE, no FIX. So +this stream polls `GET /edge/rest/events?ids=...` with prices and turns the +difference between one answer and the next into the same events every other +venue's stream emits. `PollSocket` gives the polling the `recv`/`send`/ +`close` shape the base `Stream` drives, so its reconnects, backoff and +watchdog are unchanged: an HTTP failure is a dropped connection, and the +next poll after it is a fresh snapshot. + +Polling has a budget, and this stream keeps to it: + +* One request reads twenty Matchbook events with every market, runner and + price in them, so the books, the tickers and the statuses of everything + watched come from the same reads. Every request goes through the REST + adapter's `LIMITER` (Matchbook's events group allows 700 a minute). +* Every GET is chargeable on Matchbook's API plans (£100 per million a + calendar month). The default interval, 5 s, is 12 requests a minute for up + to twenty events: about 520,000 a month, if left running. Raise `interval` + to spend less; changes show up seconds late either way. + +Books are read on YES exactly as the REST adapter reads them: `lay` prices +(resting backs) are YES bids, `back` prices (resting lays) YES asks, at 1/O, +sizes in contracts (stake times odds). + +Matchbook publishes no trades, so there is no `watch_trades`. +""" +from __future__ import annotations + +import asyncio +import json +import logging +from decimal import Decimal +from typing import Any + +import httpx + +from .. import ids +from ..base import Capability, RateLimiter +from .base import BookEvent, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms +from .betfair import changed, yes_price + +log = logging.getLogger("synpath.ws.matchbook") + +VENUE = "matchbook" + +DEFAULT_INTERVAL = 5.0 +"""Seconds between polls: 12 requests a minute for up to twenty events.""" + +MAX_EVENTS = 200 +"""Matchbook events one stream polls; ten requests a poll at most.""" + + +def yes_levels(prices: Any, side: str) -> list[tuple[Decimal, Decimal]]: + """One side's price rows as YES (price, contracts) pairs.""" + out = [] + for row in prices or []: + if not row or row.get("side") != side: + continue + odds, amount = Decimal(str(row.get("decimal-odds") or row.get("odds") or 0)), \ + Decimal(str(row.get("available-amount") or 0)) + if odds > 1 and amount > 0: + out.append((yes_price(odds), amount * odds)) + return out + + +def state_of(event_status: str | None, market_status: str | None, runner: dict[str, Any]) -> tuple[str, str | None]: + """A runner's stream state and result from the event's, market's and its own status.""" + if runner.get("withdrawn") or runner.get("status") == "paid": + return "settled", "void" if runner.get("withdrawn") else None + if "graded" in (event_status, market_status): + return "settled", None + if "closed" in (event_status, market_status, runner.get("status")): + return "closed", None + if "suspended" in (event_status, market_status, runner.get("status")): + return "paused", None + if market_status == "open": + return "open", None + return "updated", None + + +class PollSocket: + """One "connection" of polls: `recv` waits out the interval (not before the + first poll) and returns the next poll's answer as JSON text.""" + + def __init__(self, stream: "MatchbookMarketStream"): + self.stream = stream + self._first = True + self._closed = False + + async def recv(self) -> str: + if not self._first: + await asyncio.sleep(self.stream.interval) + self._first = False + if self._closed: + raise ConnectionError("closed") + return json.dumps(await self.stream.poll()) + + async def send(self, text: str) -> None: + """Subscriptions are local: what to poll is read from the stream.""" + + async def close(self) -> None: + self._closed = True + + +class MatchbookMarketStream(Stream): + """Runner books, top of book and market status for Matchbook markets, + polled from REST. No account needed. + + ```python + async with MatchbookMarketStream() as stream: + await stream.watch_order_book(["matchbook:34569494493900023-34569495835700023-34569495836701023"]) + async for event in stream: + ... + ``` + """ + + venue = VENUE + name = "market" + data_heartbeat = True + has: dict[str, Capability] = { + # Polled: a change shows up at the next poll, not as it happens. + "watch_order_book": "partial", + "watch_ticker": "partial", + "watch_trades": False, + "watch_market_status": "partial", + } + + def __init__( + self, *, credentials: Any = None, session: Any = None, interval: float = DEFAULT_INTERVAL, + depth: int | None = None, currency: str | None = None, client: httpx.AsyncClient | None = None, + base_url: str | None = None, limiter: RateLimiter | None | str = "shared", **kwargs: Any, + ): + """No argument is needed. `credentials` or `session` send a session + token on every poll, which counts the polls against the account.""" + from ..matchbook import API_URL, LIMITER, PRICE_DEPTH, session_from + + if session is None and credentials is not None: + session = session_from(credentials) + self.session = session + if interval <= 0: + raise ValueError("interval must be positive") + self.interval = float(interval) + self.depth = depth or PRICE_DEPTH + self.currency = currency + # Silence means a stuck poll only after a few intervals. + kwargs.setdefault("idle_timeout", max(60.0, self.interval * 3 + 30)) + self.base_url = (base_url or API_URL).rstrip("/") + super().__init__(f"{self.base_url}/edge/rest/events", **kwargs) + self._client = client + self._own_client = client is None + self.limiter = LIMITER if limiter == "shared" else limiter + self.books: dict[str, LocalBook] = {} + """Runner's Synpath id -> its YES book.""" + self.tickers: set[str] = set() + self.whole_markets: set[tuple[str, str]] = set() + """(event id, market id) watched as a whole: every runner's book is kept.""" + self.statuses: set[tuple[str, str]] | None = None + """(event id, market id) whose status changes are passed on; `None` while not watching.""" + self.wanted: dict[str, set[str]] = {} + """Matchbook event id -> market ids polled in it.""" + self.polls = 0 + self._top: dict[str, tuple[Any, ...]] = {} + self._last_state: dict[str, tuple[str, str | None]] = {} + + # -- connection ----------------------------------------------------------- + + async def _open(self) -> Any: + if self._client is None: + from ..smarkets import user_agent + + self._client = httpx.AsyncClient( + timeout=30.0, follow_redirects=True, + headers={"User-Agent": user_agent(), "Accept": "application/json"}, + ) + return PollSocket(self) + + async def close(self) -> None: + await super().close() + if self._client is not None and self._own_client: + await self._client.aclose() + self._client = None + + def on_disconnect(self) -> None: + for book in self.books.values(): + book.invalidate() + + async def _get(self, params: dict[str, Any]) -> Any: + if self.limiter is not None: + await self.limiter.acquire_async() + from ..smarkets import user_agent + + headers = {"User-Agent": user_agent(), "Accept": "application/json"} + if self.session is not None: + headers["session-token"] = await asyncio.to_thread(self.session.token) + assert self._client is not None + response = await self._client.get(self.url, params={k: v for k, v in params.items() if v is not None}, + headers=headers) + if response.status_code == 401 and self.session is not None: + self.session.invalidate() + if response.status_code == 429: + raise ConnectionError("rate limited on events (429); Matchbook blocks for ten minutes past its limit") + if response.status_code >= 400: + raise ConnectionError(f"Matchbook answered {response.status_code} on events") + return response.json() + + async def poll(self) -> dict[str, Any]: + """One round of reads: every event watched, twenty a request, with + its markets, runners and prices.""" + from ..matchbook import ALL_STATES, EVENTS_PER_REQUEST, PRICE_PARAMS, batched + + events = sorted(self.wanted) + self.polls += 1 + if not events: + return {"op": "idle", "at": now_ms()} + message: dict[str, Any] = {"op": "poll", "at": now_ms(), "events": []} + for chunk in batched(events, EVENTS_PER_REQUEST): + body = await self._get({ + "ids": ",".join(chunk), "states": ALL_STATES, "per-page": len(chunk), **PRICE_PARAMS, + "include-prices": "true", "price-depth": self.depth, "currency": self.currency, + }) + message["events"] += [_trimmed(e, self.wanted.get(str(e.get("id")), set())) + for e in (body or {}).get("events") or [] if isinstance(e, dict)] + message["polled"] = events + return message + + # -- intents -------------------------------------------------------------- + + def _split(self, market_ids: list[str]) -> list[tuple[str, str, str | None]]: + """(event id, market id, runner id or None for the whole market) per id.""" + from ..matchbook import parse_market_id, parse_runner_id + + out = [] + for market_id in market_ids: + native = ids.native(VENUE, market_id) + if native.count("-") == 2: + out.append(parse_runner_id(native)) + else: + event, market = parse_market_id(native) + out.append((event, market, None)) + return out + + def _want(self, targets: list[tuple[str, str, str | None]]) -> set[str]: + refused = set() + for event, market, _ in targets: + if event not in self.wanted and len(self.wanted) >= MAX_EVENTS: + refused.add(event) + continue + self.wanted.setdefault(event, set()).add(market) + if refused: + self.status("error", f"more than {MAX_EVENTS} events on one stream; not polled: {', '.join(sorted(refused))}") + return refused + + async def watch_order_book(self, market_ids: list[str]) -> None: + """Runner books at every poll. A `-` pair keeps + every runner's book in that market.""" + targets = self._split(market_ids) + refused = self._want(targets) + for event, market, runner in targets: + if event in refused: + continue + if runner is None: + self.whole_markets.add((event, market)) + else: + self.books.setdefault(_synpath_id(event, market, runner), LocalBook()) + + async def watch_ticker(self, market_ids: list[str]) -> None: + """Top of book whenever a poll finds it changed, for runners.""" + targets = self._split(market_ids) + refused = self._want(targets) + self.tickers.update(_synpath_id(e, m, r) for e, m, r in targets if r is not None and e not in refused) + + async def watch_market_status(self, market_ids: list[str] | None = None) -> None: + """Status changes (suspended, reopened, closed, a runner withdrawn) of + each runner in these markets, at every poll.""" + targets = self._split(market_ids or []) + refused = self._want(targets) + self.statuses = (self.statuses or set()) | {(e, m) for e, m, _ in targets if e not in refused} + + async def unwatch(self, market_ids: list[str]) -> None: + for event, market, runner in self._split(market_ids): + prefix = f"{VENUE}:{event}-{market}-" + if runner is not None: + synpath_id = _synpath_id(event, market, runner) + self.books.pop(synpath_id, None) + self.tickers.discard(synpath_id) + self._top.pop(synpath_id, None) + else: + self.whole_markets.discard((event, market)) + for synpath_id in [s for s in (*self.books, *self.tickers) if s.startswith(prefix)]: + self.books.pop(synpath_id, None) + self.tickers.discard(synpath_id) + self._top.pop(synpath_id, None) + if self.statuses: + self.statuses.discard((event, market)) + still = any(s.startswith(prefix) for s in (*self.books, *self.tickers)) \ + or (event, market) in self.whole_markets or (event, market) in (self.statuses or set()) + if not still and event in self.wanted: + self.wanted[event].discard(market) + if not self.wanted[event]: + self.wanted.pop(event) + + def book(self, market_id: str, side: str = "yes") -> LocalBook | None: + book = self.books.get(ids.qualify(VENUE, ids.native(VENUE, market_id))) + if book is None: + return None + return book if side != "no" else book.mirrored() + + # -- messages ------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict) or message.get("op") != "poll": + return [] + events: list[Event] = [] + seen: set[str] = set() + for event in message.get("events") or []: + event_id = str(event.get("id")) + for market in event.get("markets") or []: + market_id = str(market.get("id")) + whole = (event_id, market_id) in self.whole_markets + watched_status = self.statuses is not None and (not self.statuses or (event_id, market_id) in self.statuses) + for runner in market.get("runners") or []: + synpath_id = _synpath_id(event_id, market_id, str(runner.get("id"))) + if whole: + self.books.setdefault(synpath_id, LocalBook()) + if synpath_id in self.books or synpath_id in self.tickers: + seen.add(synpath_id) + events += self._book_events(synpath_id, runner.get("prices") or [], message.get("at")) + if watched_status: + events += self._status_change(synpath_id, event.get("status"), market.get("status"), runner) + # A runner a poll no longer lists (its market closed) has an empty book. + polled = set(message.get("polled") or []) + for synpath_id in sorted(set(self.books) | self.tickers): + if synpath_id not in seen and synpath_id.split(":", 1)[1].split("-")[0] in polled: + events += self._book_events(synpath_id, [], message.get("at")) + return events + + def _book_events(self, synpath_id: str, prices: list[dict[str, Any]], at: Any) -> list[Event]: + bids, asks = yes_levels(prices, "lay"), yes_levels(prices, "back") + currency = next((row.get("currency") for row in prices if row.get("currency")), self.currency) + info = {"polled_at": at, "currency": currency} + events: list[Event] = [] + book = self.books.get(synpath_id) + if book is not None: + snapshot = not book.ready + recovering = snapshot and book.timestamp is not None + old_bids, old_asks = book.levels() + book.replace(bids, asks) + book.timestamp = now_ms() + new_bids, new_asks = book.levels() + if snapshot: + events.append(BookEvent( + venue=VENUE, market_id=synpath_id, side="yes", kind="snapshot", bids=new_bids, asks=new_asks, + best_bid=book.best_bid, best_ask=book.best_ask, info=info, + )) + if recovering: + self.status("resynced", 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: + events.append(BookEvent( + venue=VENUE, market_id=synpath_id, side="yes", kind="delta", bids=bid_changes, + asks=ask_changes, best_bid=book.best_bid, best_ask=book.best_ask, info=info, + )) + 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]) + top = (best_bid, best_ask) + if self._top.get(synpath_id) != top: + self._top[synpath_id] = top + events.append(QuoteEvent( + venue=VENUE, market_id=synpath_id, + bid=best_bid[0] if best_bid else None, bid_size=best_bid[1] if best_bid else None, + ask=best_ask[0] if best_ask else None, ask_size=best_ask[1] if best_ask else None, + info=info, + )) + return events + + def _status_change(self, synpath_id: str, event_status: Any, market_status: Any, runner: dict[str, Any]) -> list[Event]: + state, result = state_of(event_status, market_status, runner) + if self._last_state.get(synpath_id) == (state, result): + return [] + self._last_state[synpath_id] = (state, result) + return [MarketStatusEvent( + venue=VENUE, market_id=synpath_id, state=state, # type: ignore[arg-type] + native=f"{market_status}/{runner.get('status')}", result=result, + info={"event_status": event_status, "withdrawn": bool(runner.get("withdrawn"))}, + )] + + +def _trimmed(event: dict[str, Any], markets: set[str]) -> dict[str, Any]: + """An event with only the markets watched in it, so a poll's message stays small.""" + return {**event, "markets": [m for m in event.get("markets") or [] if str(m.get("id")) in markets]} + + +def _synpath_id(event: str, market: str, runner: str) -> str: + return ids.qualify(VENUE, f"{event}-{market}-{runner}") diff --git a/tests/samples/matchbook_closed.json b/tests/samples/matchbook_closed.json new file mode 100644 index 0000000..06cf315 --- /dev/null +++ b/tests/samples/matchbook_closed.json @@ -0,0 +1,98 @@ +{ + "event": { + "id": 34257819090800081, + "name": "Walker Cup 2026 BT", + "sport-id": 8, + "start": "2026-09-05T07:00:00.000Z", + "in-running-flag": false, + "allow-live-betting": false, + "category-id": [ + 8, + 10812641776701, + 292022482330036 + ], + "status": "closed", + "volume": 12336.06434, + "markets": [], + "meta-tags": [ + { + "id": 8, + "name": "Golf", + "type": "SPORT", + "url-name": "golf" + }, + { + "id": 10812641776701, + "name": "Ireland", + "type": "COUNTRY", + "url-name": "ireland" + }, + { + "id": 292022482330036, + "name": "Block Trade", + "type": "OTHER", + "url-name": "block-trade" + } + ] + }, + "markets": { + "markets": [ + { + "live": false, + "event-id": 34257819090800081, + "id": 34257827385400081, + "name": "Winner", + "product": "EXCHANGE", + "start": "2026-09-05T07:00:00.000Z", + "status": "closed", + "market-type": "one_x_two", + "type": "multirunner", + "in-running-flag": false, + "allow-live-betting": false, + "volume": 12336.06434, + "number-of-winners": 1, + "runners": [] + } + ], + "offset": 0, + "total": 1, + "per-page": 20 + }, + "runners": { + "runners": [ + { + "withdrawn": false, + "prices": [], + "event-id": 34257819090800081, + "id": 34257827385600081, + "market-id": 34257827385400081, + "name": "GB & Ireland", + "status": "closed", + "volume": 12336.06434, + "event-participant-id": 34257820894700081 + }, + { + "withdrawn": false, + "prices": [], + "event-id": 34257819090800081, + "id": 34257827385900081, + "market-id": 34257827385400081, + "name": "USA ㅤ", + "status": "closed", + "volume": 0.0, + "event-participant-id": 34257823438500081 + }, + { + "withdrawn": false, + "prices": [], + "event-id": 34257819090800081, + "id": 34257827386200081, + "market-id": 34257827385400081, + "name": "DRAW (GB /USA)", + "status": "closed", + "volume": 0.0 + } + ], + "total": 3 + } +} diff --git a/tests/samples/matchbook_errors.json b/tests/samples/matchbook_errors.json new file mode 100644 index 0000000..4f6b05e --- /dev/null +++ b/tests/samples/matchbook_errors.json @@ -0,0 +1,14 @@ +{ + "not_found": { + "errors": [ + { + "messages": [ + "Resource not found." + ], + "codes": [ + null + ] + } + ] + } +} diff --git a/tests/samples/matchbook_events.json b/tests/samples/matchbook_events.json new file mode 100644 index 0000000..532df75 --- /dev/null +++ b/tests/samples/matchbook_events.json @@ -0,0 +1,1670 @@ +{ + "offset": 0, + "per-page": 20, + "total": 3, + "events": [ + { + "id": 34662085370500072, + "name": "17:45 Dundalk", + "sport-id": 24735152712200, + "start": "2026-10-23T16:45:00.000Z", + "in-running-flag": false, + "allow-live-betting": true, + "category-id": [ + 10812641776701, + 24735152712200, + 24743063490401, + 410468520880009, + 1507462493830003 + ], + "status": "open", + "volume": 93519.69312, + "markets": [ + { + "live": false, + "event-id": 34662085370500072, + "id": 34662085463000072, + "name": "WIN", + "product": "EXCHANGE", + "start": "2026-10-23T16:45:00.000Z", + "status": "open", + "market-type": "outright_ded_fact", + "type": "multirunner", + "in-running-flag": false, + "allow-live-betting": true, + "volume": 93516.81113, + "back-overround": 101.8457, + "lay-overround": 97.40835, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 1.85999, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 5.5, + "decimal-odds": 5.5, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 43.5917, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 5.4, + "decimal-odds": 5.4, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 47.28643, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 5.3, + "decimal-odds": 5.3, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 2.11, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 5.9, + "decimal-odds": 5.9, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 18.89234, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 6.0, + "decimal-odds": 6.0, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 27.20512, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 6.2, + "decimal-odds": 6.2, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:10:13.827Z", + "event-id": 34662085370500072, + "id": 34662085466302072, + "market-id": 34662085463000072, + "name": "14 Eichan San", + "status": "open", + "volume": 6633.8006, + "event-participant-id": 34662085395401072 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 1.44, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 5.9, + "decimal-odds": 5.9, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 25.01879, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 5.8, + "decimal-odds": 5.8, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 22.67656, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 5.7, + "decimal-odds": 5.7, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 21.96604, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 6.0, + "decimal-odds": 6.0, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 16.02171, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 6.2, + "decimal-odds": 6.2, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 22.87209, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 6.4, + "decimal-odds": 6.4, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:08:15.348Z", + "event-id": 34662085370500072, + "id": 34662085464307072, + "market-id": 34662085463000072, + "name": "3 Mehman", + "status": "open", + "volume": 6830.76968, + "event-participant-id": 34662085393602072 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 5.09372, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.2, + "decimal-odds": 8.2, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 31.14275, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.0, + "decimal-odds": 8.0, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 23.49717, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 7.8, + "decimal-odds": 7.8, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 4.21308, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.4, + "decimal-odds": 8.4, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 36.08636, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.6, + "decimal-odds": 8.6, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 27.26993, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.8, + "decimal-odds": 8.8, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:07:42.565Z", + "event-id": 34662085370500072, + "id": 34662085465708072, + "market-id": 34662085463000072, + "name": "11 Iff In Doubt", + "status": "open", + "volume": 6861.10236, + "event-participant-id": 34662085394902072 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 12.12886, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 9.2, + "decimal-odds": 9.2, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 16.49205, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 9.0, + "decimal-odds": 9.0, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 26.26919, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.8, + "decimal-odds": 8.8, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 2.68999, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 9.6, + "decimal-odds": 9.6, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 2.50183, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 9.8, + "decimal-odds": 9.8, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 25.72881, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 10.0, + "decimal-odds": 10.0, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:08:49.160Z", + "event-id": 34662085370500072, + "id": 34662085464706072, + "market-id": 34662085463000072, + "name": "5 Faoladh", + "status": "open", + "volume": 7142.83887, + "event-participant-id": 34662085393902072 + }, + { + "withdrawn": true, + "prices": [], + "last-price-update-time": "2026-10-23T10:13:40.908Z", + "event-id": 34662085370500072, + "id": 34662085466803072, + "market-id": 34662085463000072, + "name": "17 Boston Benjamin", + "status": "paid", + "volume": 6637.03438, + "event-participant-id": 34662085395901072 + } + ] + } + ], + "meta-tags": [ + { + "id": 10812641776701, + "name": "Ireland", + "type": "COUNTRY", + "url-name": "ireland" + }, + { + "id": 24735152712200, + "name": "Horse Racing", + "type": "SPORT", + "url-name": "horse-racing" + }, + { + "id": 24743063490401, + "name": "Dundalk", + "type": "LOCATION", + "url-name": "dundalk" + }, + { + "id": 410468520880009, + "name": "Live Betting", + "type": "OTHER", + "url-name": "live-betting" + }, + { + "id": 1507462493830003, + "name": "UK Ireland", + "type": "OTHER", + "url-name": "uk-ireland" + } + ], + "race-length": "7f", + "race-type": "Apprentice Jockeys - Handicap" + }, + { + "id": 34569494493900023, + "name": "Arsenal vs Everton", + "sport-id": 15, + "start": "2026-10-24T14:00:00.000Z", + "in-running-flag": false, + "allow-live-betting": true, + "category-id": [ + 15, + 400746807120010, + 410468520880009, + 1931978925580001, + 33920141522300065 + ], + "status": "open", + "volume": 22082.45681, + "markets": [ + { + "live": false, + "event-id": 34569494493900023, + "id": 34569495835700023, + "name": "Match Odds", + "product": "EXCHANGE", + "start": "2026-10-24T14:00:00.000Z", + "status": "open", + "market-type": "one_x_two", + "type": "multirunner", + "in-running-flag": false, + "allow-live-betting": true, + "volume": 14356.57914, + "back-overround": 100.69997, + "lay-overround": 99.5169, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 4394.55779, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.49, + "decimal-odds": 1.49, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 5234.39256, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.48, + "decimal-odds": 1.48, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 10655.23122, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.47, + "decimal-odds": 1.47, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 1388.11838, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.5, + "decimal-odds": 1.5, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 4389.47284, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.51, + "decimal-odds": 1.51, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 3469.28206, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.52, + "decimal-odds": 1.52, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:55.214Z", + "event-id": 34569494493900023, + "id": 34569495836701023, + "market-id": 34569495835700023, + "name": "Arsenal", + "status": "open", + "volume": 6330.96959, + "event-participant-id": 34569494496600023 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 138.10089, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.8, + "decimal-odds": 8.8, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 878.4087, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.6, + "decimal-odds": 8.6, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 440.17694, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 8.4, + "decimal-odds": 8.4, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 5.43466, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 9.0, + "decimal-odds": 9.0, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 726.00399, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 9.2, + "decimal-odds": 9.2, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 592.33521, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 9.4, + "decimal-odds": 9.4, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:14.228Z", + "event-id": 34569494493900023, + "id": 34569495836901023, + "market-id": 34569495835700023, + "name": "Everton", + "status": "open", + "volume": 2764.51274, + "event-participant-id": 34569494496801023 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 989.70403, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 4.5, + "decimal-odds": 4.5, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 1112.23564, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 4.4, + "decimal-odds": 4.4, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 1620.31534, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 4.3, + "decimal-odds": 4.3, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 1243.97301, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 4.7, + "decimal-odds": 4.7, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 1464.14086, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 4.8, + "decimal-odds": 4.8, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 2385.66851, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 4.9, + "decimal-odds": 4.9, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:24.475Z", + "event-id": 34569494493900023, + "id": 34569495837101023, + "market-id": 34569495835700023, + "name": "Draw", + "status": "open", + "volume": 5261.0968 + } + ] + }, + { + "live": false, + "event-id": 34569494493900023, + "id": 34569506151400023, + "name": "Total", + "product": "EXCHANGE", + "start": "2026-10-24T14:00:00.000Z", + "status": "open", + "market-type": "total", + "type": "binary", + "handicap": 2.5, + "in-running-flag": false, + "allow-live-betting": true, + "volume": 483.80385, + "back-overround": 100.56601, + "lay-overround": 99.3051, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 10.18744, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.94, + "decimal-odds": 1.94, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 71.58599, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.93, + "decimal-odds": 1.93, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 309.25084, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.92, + "decimal-odds": 1.92, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 21.08912, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.97, + "decimal-odds": 1.97, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 26.01721, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.98, + "decimal-odds": 1.98, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 291.49108, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.99, + "decimal-odds": 1.99, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:56.243Z", + "event-id": 34569494493900023, + "id": 34569506152501023, + "market-id": 34569506151400023, + "name": "OVER 2.5", + "status": "open", + "handicap": 2.5, + "volume": 266.779 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 2.00146, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.04, + "decimal-odds": 2.04, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 141.67298, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.02, + "decimal-odds": 2.02, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 538.01901, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.0, + "decimal-odds": 2.0, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 3.00219, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.06, + "decimal-odds": 2.06, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 176.16339, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.08, + "decimal-odds": 2.08, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 536.16263, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.1, + "decimal-odds": 2.1, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:56.243Z", + "event-id": 34569494493900023, + "id": 34569506152701023, + "market-id": 34569506151400023, + "name": "UNDER 2.5", + "status": "open", + "handicap": 2.5, + "volume": 217.02484 + } + ] + }, + { + "live": false, + "event-id": 34569494493900023, + "id": 34569506954100023, + "name": "Handicap", + "product": "EXCHANGE", + "start": "2026-10-24T14:00:00.000Z", + "status": "open", + "market-type": "handicap", + "type": "binary", + "handicap": -1, + "in-running-flag": false, + "allow-live-betting": false, + "volume": 98.39558, + "back-overround": 101.12018, + "lay-overround": 98.99067, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 40.02924, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.81, + "decimal-odds": 1.81, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 96.7907, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.8, + "decimal-odds": 1.8, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 233.22394, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.78, + "decimal-odds": 1.78, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 47.03436, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.84, + "decimal-odds": 1.84, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 207.70531, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.85, + "decimal-odds": 1.85, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 189.14403, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.87, + "decimal-odds": 1.87, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:30.499Z", + "event-id": 34569494493900023, + "id": 34569506956200023, + "market-id": 34569506954100023, + "name": "Arsenal -1.0", + "status": "open", + "handicap": -1, + "volume": 0.0, + "event-participant-id": 34569494496600023 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 215.53031, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.18, + "decimal-odds": 2.18, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 56.0805, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.16, + "decimal-odds": 2.16, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 107.95887, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.14, + "decimal-odds": 2.14, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 110.48071, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.24, + "decimal-odds": 2.24, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 373.27021, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.3, + "decimal-odds": 2.3, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 73.7656, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.32, + "decimal-odds": 2.32, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:30.499Z", + "event-id": 34569494493900023, + "id": 34569506956401023, + "market-id": 34569506954100023, + "name": "Everton +1.0", + "status": "open", + "handicap": 1, + "volume": 98.39558, + "event-participant-id": 34569494496801023 + } + ] + }, + { + "live": false, + "event-id": 34569494493900023, + "id": 34569507475400023, + "name": "Handicap", + "product": "EXCHANGE", + "start": "2026-10-24T14:00:00.000Z", + "status": "open", + "market-type": "handicap", + "type": "binary", + "asian-handicap": "-0.5/1.0", + "in-running-flag": false, + "allow-live-betting": false, + "volume": 0.0, + "back-overround": 100.8715, + "lay-overround": 99.22847, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 121.08846, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.61, + "decimal-odds": 1.61, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 147.70791, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.6, + "decimal-odds": 1.6, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 777.54776, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.58, + "decimal-odds": 1.58, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 211.93483, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.63, + "decimal-odds": 1.63, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 200.5221, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.64, + "decimal-odds": 1.64, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 503.98334, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.66, + "decimal-odds": 1.66, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:40.574Z", + "event-id": 34569494493900023, + "id": 34569507476201023, + "market-id": 34569507475400023, + "name": "Arsenal (-0.5/1.0)", + "status": "open", + "asian-handicap": "-0.5/1.0", + "volume": 0.0, + "event-participant-id": 34569494496600023 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 260.43166, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.58, + "decimal-odds": 2.58, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 184.86553, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.52, + "decimal-odds": 2.52, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 147.1719, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.5, + "decimal-odds": 2.5, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 163.92976, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.64, + "decimal-odds": 2.64, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 84.06141, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.7, + "decimal-odds": 2.7, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 152.64564, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.72, + "decimal-odds": 2.72, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:40.574Z", + "event-id": 34569494493900023, + "id": 34569507476401023, + "market-id": 34569507475400023, + "name": "Everton (+0.5/1.0)", + "status": "open", + "asian-handicap": "+0.5/1.0", + "volume": 0.0, + "event-participant-id": 34569494496801023 + } + ] + }, + { + "live": false, + "event-id": 34569494493900023, + "id": 34569494509300023, + "name": "Both Teams To Score", + "product": "EXCHANGE", + "start": "2026-10-24T14:00:00.000Z", + "status": "open", + "market-type": "both_to_score", + "type": "binary", + "in-running-flag": false, + "allow-live-betting": true, + "volume": 78.77163, + "back-overround": 101.07681, + "lay-overround": 99.06303, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 276.27643, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.12, + "decimal-odds": 2.12, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 459.58485, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.1, + "decimal-odds": 2.1, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 664.92327, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.08, + "decimal-odds": 2.08, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 27.02851, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.18, + "decimal-odds": 2.18, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 1054.72123, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.2, + "decimal-odds": 2.2, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 360.56996, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.22, + "decimal-odds": 2.22, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:19.078Z", + "event-id": 34569494493900023, + "id": 34569494509800023, + "market-id": 34569494509300023, + "name": "Yes", + "status": "open", + "volume": 32.17377 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 150.01827, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.84, + "decimal-odds": 1.84, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 1580.79798, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.83, + "decimal-odds": 1.83, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 142.85068, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.8, + "decimal-odds": 1.8, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 687.74343, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.9, + "decimal-odds": 1.9, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 860.41077, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.91, + "decimal-odds": 1.91, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 43.24807, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.99, + "decimal-odds": 1.99, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:19.078Z", + "event-id": 34569494493900023, + "id": 34569494510001023, + "market-id": 34569494509300023, + "name": "No", + "status": "open", + "volume": 46.59786 + } + ] + } + ], + "meta-tags": [ + { + "id": 15, + "name": "Soccer", + "type": "SPORT", + "url-name": "soccer" + }, + { + "id": 400746807120010, + "name": "England", + "type": "COUNTRY", + "url-name": "england" + }, + { + "id": 410468520880009, + "name": "Live Betting", + "type": "OTHER", + "url-name": "live-betting" + }, + { + "id": 1931978925580001, + "name": "England Premier League", + "type": "COMPETITION", + "url-name": "english-premier-league" + }, + { + "id": 33920141522300065, + "name": "October 24th 2026", + "type": "DATE", + "url-name": "october-24th-2026" + } + ] + }, + { + "id": 34569460096700023, + "name": "Coventry City vs Fulham", + "sport-id": 15, + "start": "2026-10-24T14:00:00.000Z", + "in-running-flag": false, + "allow-live-betting": true, + "category-id": [ + 15, + 400746807120010, + 410468520880009, + 1931978925580001, + 33920141522300065 + ], + "status": "open", + "volume": 12897.73677, + "markets": [ + { + "live": false, + "event-id": 34569460096700023, + "id": 34569462116700023, + "name": "Match Odds", + "product": "EXCHANGE", + "start": "2026-10-24T14:00:00.000Z", + "status": "open", + "market-type": "one_x_two", + "type": "multirunner", + "in-running-flag": false, + "allow-live-betting": true, + "volume": 9313.57851, + "back-overround": 100.36358, + "lay-overround": 99.1819, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 19.02007, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.35, + "decimal-odds": 3.35, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 325.33491, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.3, + "decimal-odds": 3.3, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 1320.52282, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.25, + "decimal-odds": 3.25, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 472.55199, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.4, + "decimal-odds": 3.4, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 442.15604, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.45, + "decimal-odds": 3.45, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 616.84857, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.5, + "decimal-odds": 3.5, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:07:21.407Z", + "event-id": 34569460096700023, + "id": 34569462119901023, + "market-id": 34569462116700023, + "name": "Coventry City", + "status": "open", + "volume": 2114.5396, + "event-participant-id": 34569460100700023 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 148.98094, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.34, + "decimal-odds": 2.34, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 274.55649, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.32, + "decimal-odds": 2.32, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 976.02138, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.3, + "decimal-odds": 2.3, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 191.76475, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.36, + "decimal-odds": 2.36, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 650.55597, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.38, + "decimal-odds": 2.38, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 1085.13649, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.4, + "decimal-odds": 2.4, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:07:10.336Z", + "event-id": 34569460096700023, + "id": 34569462120101023, + "market-id": 34569462116700023, + "name": "Fulham", + "status": "open", + "volume": 1870.93198, + "event-participant-id": 34569460101200023 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 2388.82336, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.6, + "decimal-odds": 3.6, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 7271.42657, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.55, + "decimal-odds": 3.55, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 4770.29089, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.5, + "decimal-odds": 3.5, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 6073.7542, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.7, + "decimal-odds": 3.7, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 4889.94796, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.75, + "decimal-odds": 3.75, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 2279.51703, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 3.8, + "decimal-odds": 3.8, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:13.290Z", + "event-id": 34569460096700023, + "id": 34569462120301023, + "market-id": 34569462116700023, + "name": "Draw", + "status": "open", + "volume": 5328.10693 + } + ] + }, + { + "live": false, + "event-id": 34569460096700023, + "id": 34569462118700023, + "name": "Total", + "product": "EXCHANGE", + "start": "2026-10-24T14:00:00.000Z", + "status": "open", + "market-type": "total", + "type": "binary", + "handicap": 2.5, + "in-running-flag": false, + "allow-live-betting": true, + "volume": 218.53031, + "back-overround": 101.01011, + "lay-overround": 99.19283, + "number-of-winners": 1, + "runners": [ + { + "withdrawn": false, + "prices": [ + { + "available-amount": 80.10108, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.8, + "decimal-odds": 1.8, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 229.36197, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.79, + "decimal-odds": 1.79, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 663.54003, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.78, + "decimal-odds": 1.78, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 9.00658, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.82, + "decimal-odds": 1.82, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 43.99413, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.84, + "decimal-odds": 1.84, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 339.73843, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.85, + "decimal-odds": 1.85, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:42.404Z", + "event-id": 34569460096700023, + "id": 34569462126901023, + "market-id": 34569462118700023, + "name": "OVER 2.5", + "status": "open", + "handicap": 2.5, + "volume": 0.0 + }, + { + "withdrawn": false, + "prices": [ + { + "available-amount": 7.3854, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.2, + "decimal-odds": 2.2, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 178.10398, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.18, + "decimal-odds": 2.18, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 450.42279, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.16, + "decimal-odds": 2.16, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 126.14634, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.26, + "decimal-odds": 2.26, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 322.63037, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.28, + "decimal-odds": 2.28, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 553.20653, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 2.3, + "decimal-odds": 2.3, + "side": "lay", + "exchange-type": "back-lay" + } + ], + "last-price-update-time": "2026-10-23T16:09:42.404Z", + "event-id": 34569460096700023, + "id": 34569462127101023, + "market-id": 34569462118700023, + "name": "UNDER 2.5", + "status": "open", + "handicap": 2.5, + "volume": 218.53031 + } + ] + } + ], + "meta-tags": [ + { + "id": 15, + "name": "Soccer", + "type": "SPORT", + "url-name": "soccer" + }, + { + "id": 400746807120010, + "name": "England", + "type": "COUNTRY", + "url-name": "england" + }, + { + "id": 410468520880009, + "name": "Live Betting", + "type": "OTHER", + "url-name": "live-betting" + }, + { + "id": 1931978925580001, + "name": "England Premier League", + "type": "COMPETITION", + "url-name": "english-premier-league" + }, + { + "id": 33920141522300065, + "name": "October 24th 2026", + "type": "DATE", + "url-name": "october-24th-2026" + } + ] + } + ] +} diff --git a/tests/samples/matchbook_prices.json b/tests/samples/matchbook_prices.json new file mode 100644 index 0000000..fc50d81 --- /dev/null +++ b/tests/samples/matchbook_prices.json @@ -0,0 +1,94 @@ +{ + "prices": [ + { + "available-amount": 4394.55779, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.49, + "decimal-odds": 1.49, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 5234.39256, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.48, + "decimal-odds": 1.48, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 10655.23122, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.47, + "decimal-odds": 1.47, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 282.12344, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.46, + "decimal-odds": 1.46, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 113.21579, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.45, + "decimal-odds": 1.45, + "side": "back", + "exchange-type": "back-lay" + }, + { + "available-amount": 1388.11838, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.5, + "decimal-odds": 1.5, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 4389.47284, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.51, + "decimal-odds": 1.51, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 3469.28206, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.52, + "decimal-odds": 1.52, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 270.93743, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.53, + "decimal-odds": 1.53, + "side": "lay", + "exchange-type": "back-lay" + }, + { + "available-amount": 11.43702, + "currency": "GBP", + "odds-type": "DECIMAL", + "odds": 1.54, + "decimal-odds": 1.54, + "side": "lay", + "exchange-type": "back-lay" + } + ] +} diff --git a/tests/samples/matchbook_sports.json b/tests/samples/matchbook_sports.json new file mode 100644 index 0000000..cc46146 --- /dev/null +++ b/tests/samples/matchbook_sports.json @@ -0,0 +1,25 @@ +{ + "total": 3, + "per-page": 60, + "offset": 0, + "sports": [ + { + "name": "Horse Racing", + "id": 24735152712200, + "type": "SPORT", + "url-name": "horse-racing" + }, + { + "name": "Soccer", + "id": 15, + "type": "SPORT", + "url-name": "soccer" + }, + { + "name": "Tennis", + "id": 9, + "type": "SPORT", + "url-name": "tennis" + } + ] +} diff --git a/tests/samples/matchbook_trading.json b/tests/samples/matchbook_trading.json new file mode 100644 index 0000000..3c96173 --- /dev/null +++ b/tests/samples/matchbook_trading.json @@ -0,0 +1,606 @@ +{ + "login": { + "session-token": "68fef7b56bd069e9296182b55b194a", + "user-id": 1234, + "role": "USER", + "account": { + "id": 5678, + "username": "jbloggs", + "name": { + "first": "Joe", + "last": "Bloggs" + }, + "email": "jbloggs@matchbook.com", + "phone-number": "555555555", + "address": { + "address-id": 789, + "address-line-1": "1 Magic Avenue", + "region-name": "Great Place", + "post-code": "", + "country": { + "country-id": 44, + "name": "United Kingdom", + "country-code": "UK" + } + }, + "date-of-birth": "1970-01-01T00:00:00.000Z", + "currency": "GBP", + "balance": 100.0, + "exposure": 0.0, + "commission-credit": 0.0, + "free-funds": 100.0, + "language": "English", + "odds-type": "US", + "bet-confirmation": false, + "display-p-and-l": true, + "exchange-type": "back-lay", + "odds-rounding": false, + "bet-slip-pinned": true + }, + "mfa-enabled": true, + "mfa-enabled-time": "2018-07-30T10:46:39.000Z", + "mfa-disabled-time": "2018-07-30T10:31:13.000Z", + "last-login": "2017-01-01T00:00:00.000Z" + }, + "login_refused": { + "errors": [ + { + "messages": [ + "Username or password are incorrect." + ] + } + ] + }, + "login_mfa": { + "errors": [ + { + "field": "mfa-code", + "messages": [ + "Multi factor authentication is enabled. Multi factor authentication code must be provided." + ], + "codes": [ + "LOGIN_MFA_REQUIRED" + ] + } + ] + }, + "submit_unmatched": { + "language": "en", + "currency": "EUR", + "exchange-type": "back-lay", + "odds-type": "DECIMAL", + "offers": [ + { + "id": 413177013410013, + "event-id": 410343160570009, + "event-name": "Man Utd vs St Etienne", + "market-id": 410343161840009, + "market-name": "Match Odds", + "market-type": "one_x_two", + "runner-id": 401525949430009, + "runner-name": "Man Utd", + "exchange-type": "back-lay", + "side": "back", + "odds": 2.4, + "odds-type": "DECIMAL", + "decimal-odds": 2.4, + "stake": 5, + "remaining": 5, + "potential-profit": 7, + "remaining-potential-profit": 7, + "commission-type": "VOLUME", + "originator-commission-rate": 0, + "acceptor-commission-rate": 0, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-02-16T19:49:32.771Z", + "status": "open", + "in-play": false + } + ] + }, + "submit_matched": { + "language": "en", + "currency": "EUR", + "exchange-type": "back-lay", + "odds-type": "DECIMAL", + "offers": [ + { + "id": 413177013410013, + "event-id": 410343160570009, + "event-name": "Man Utd vs St Etienne", + "market-id": 410343161840009, + "market-name": "Match Odds", + "market-type": "one_x_two", + "runner-id": 401525949430009, + "runner-name": "Man Utd", + "exchange-type": "back-lay", + "side": "back", + "odds": 2.4, + "odds-type": "DECIMAL", + "decimal-odds": 2.4, + "stake": 5, + "remaining": 5, + "potential-profit": 7, + "remaining-potential-profit": 7, + "commission-type": "VOLUME", + "originator-commission-rate": 0, + "acceptor-commission-rate": 0, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-02-16T19:49:32.771Z", + "status": "matched", + "in-play": false, + "matched-bets": [ + { + "id": 413176315170213, + "offer-id": 413176315170013, + "odds": 2.4, + "odds-type": "DECIMAL", + "decimal-odds": 2.4, + "stake": 5, + "potential-profit": 7, + "commission": 0, + "currency": "EUR", + "created-at": "2017-02-16T19:50:22.947Z", + "in-play": false, + "status": "open" + } + ] + } + ] + }, + "submit_delayed": { + "language": "en", + "currency": "EUR", + "exchange-type": "back-lay", + "odds-type": "DECIMAL", + "offers": [ + { + "id": 413177013410013, + "event-id": 410343160570009, + "event-name": "Man Utd vs St Etienne", + "market-id": 410343161840009, + "market-name": "Match Odds", + "market-type": "one_x_two", + "runner-id": 401525949430009, + "runner-name": "Man Utd", + "exchange-type": "back-lay", + "side": "back", + "odds": 2.4, + "odds-type": "DECIMAL", + "decimal-odds": 2.4, + "stake": 5, + "remaining": 5, + "potential-profit": 7, + "remaining-potential-profit": 7, + "commission-type": "VOLUME", + "originator-commission-rate": 0, + "acceptor-commission-rate": 0, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-02-16T19:49:32.771Z", + "status": "delayed", + "delay": 6, + "in-play": true + } + ] + }, + "edit": { + "id": 422497589540014, + "event-id": 500000000000072, + "event-name": "18:00 Windsor", + "market-id": 500000000000091, + "market-name": "WIN", + "market-type": "outright_ded_fact", + "runner-id": 500000000000092, + "runner-name": "1 Call Of The Loon", + "temp-id": "50", + "exchange-type": "back-lay", + "side": "back", + "odds": 3, + "odds-type": "DECIMAL", + "decimal-odds": 3, + "stake": 100, + "remaining": 100, + "potential-liability": 100, + "remaining-potential-liability": 100, + "commission-type": "VOLUME", + "originator-commission-rate": 0.01, + "acceptor-commission-rate": 0.01, + "commission-reserve": 1, + "currency": "EUR", + "created-at": "2018-10-02T10:14:16.103Z", + "status": "edited", + "in-play": false, + "offer-edit": { + "id": 925184068850125, + "offer-id": 925183846730025, + "odds-type": "DECIMAL", + "odds-before": 1.5, + "decimal-odds-before": 1.5, + "odds-after": 3, + "decimal-odds-after": 3, + "stake-before": 50, + "stake-after": 100, + "status": "applied", + "delay": 6, + "edit-time": "2018-10-02T10:14:38.315Z" + } + }, + "edit_refused": { + "id": 925183846730025, + "offer-edit": { + "errors": [ + { + "field": "current-odds", + "messages": [ + "Wrong current odds value of DECIMAL 1.5. Current odds are DECIMAL 3.0." + ] + }, + { + "field": "current-stake", + "messages": [ + "Wrong current stake value of 50. Current remaining stake is 48.00001." + ] + } + ] + } + }, + "cancel": { + "id": 413775799780013, + "event-id": 412878712890009, + "event-name": "T Berdych vs M Klizan", + "market-id": 412878713090010, + "market-name": "Moneyline", + "market-type": "one_x_two", + "runner-id": 412878713120009, + "runner-name": "T Berdych", + "exchange-type": "back-lay", + "side": "back", + "odds": 1.1, + "odds-type": "DECIMAL", + "decimal-odds": 1.1, + "stake": 2, + "remaining": 2, + "potential-profit": 0.2, + "remaining-potential-profit": 0.2, + "commission-type": "VOLUME", + "originator-commission-rate": 0, + "acceptor-commission-rate": 0, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-02-17T12:27:31.408Z", + "status": "cancelled", + "in-play": true, + "delay": 6 + }, + "cancel_refused": { + "errors": [ + { + "field": "status", + "messages": [ + "Cannot cancel offer with status cancelled. Offer must be open or edited to cancel." + ] + } + ] + }, + "cancel_many": { + "language": "en", + "currency": "EUR", + "exchange-type": "back-lay", + "odds-type": "DECIMAL", + "offers": [ + { + "id": 413775799780013, + "event-id": 412878712890009, + "event-name": "T Berdych vs M Klizan", + "market-id": 412878713090010, + "market-name": "Moneyline", + "market-type": "one_x_two", + "runner-id": 412878713120009, + "runner-name": "T Berdych", + "exchange-type": "back-lay", + "side": "back", + "odds": 1.1, + "odds-type": "DECIMAL", + "decimal-odds": 1.1, + "stake": 2, + "remaining": 2, + "potential-profit": 0.2, + "remaining-potential-profit": 0.2, + "commission-type": "VOLUME", + "originator-commission-rate": 0, + "acceptor-commission-rate": 0, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-02-17T12:27:31.408Z", + "status": "cancelled", + "in-play": false + } + ] + }, + "offer_lay": { + "id": 382937981320019, + "event-id": 370600115730031, + "event-name": "Champion Chase (Ante Post)", + "market-id": 370602322080031, + "market-name": "WIN", + "market-type": "outright", + "runner-id": 370602323320027, + "runner-name": "Douvan", + "exchange-type": "back-lay", + "side": "lay", + "odds": -196, + "odds-type": "US", + "decimal-odds": 1.51021, + "stake": 2, + "remaining": 0, + "potential-liability": 1.02041, + "remaining-potential-liability": 0, + "commission-type": "VOLUME", + "originator-commission-rate": 0, + "acceptor-commission-rate": 0, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-01-12T19:51:09.562Z", + "status": "matched", + "in-play": false, + "keep-in-play": false, + "matched-bets": [ + { + "id": 382937981320219, + "offer-id": 382937981320019, + "odds": -196, + "odds-type": "US", + "decimal-odds": 1.51021, + "stake": 2, + "potential-liability": 1.02041, + "commission": 1.02041, + "currency": "EUR", + "created-at": "2017-01-12T19:51:09.562Z", + "block-trade-id": 0, + "status": "open" + } + ] + }, + "offers": { + "language": "en", + "currency": "EUR", + "exchange-type": "back-lay", + "odds-type": "DECIMAL", + "offers": [ + { + "id": 413177013410013, + "event-id": 410343160570009, + "event-name": "Man Utd vs St Etienne", + "market-id": 410343161840009, + "market-name": "Match Odds", + "market-type": "one_x_two", + "runner-id": 401525949430009, + "runner-name": "Man Utd", + "exchange-type": "back-lay", + "side": "back", + "odds": 2.4, + "odds-type": "DECIMAL", + "decimal-odds": 2.4, + "stake": 5, + "remaining": 5, + "potential-profit": 7, + "remaining-potential-profit": 7, + "commission-type": "VOLUME", + "originator-commission-rate": 0, + "acceptor-commission-rate": 0, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-02-16T19:49:32.771Z", + "status": "matched", + "in-play": false, + "keep-in-play": false, + "matched-bets": [ + { + "id": 413176315170213, + "offer-id": 413176315170013, + "odds": 2.4, + "odds-type": "DECIMAL", + "decimal-odds": 2.4, + "stake": 5, + "potential-profit": 7, + "commission": 0, + "currency": "EUR", + "created-at": "2017-02-16T19:50:22.947Z", + "in-play": false, + "status": "open" + } + ] + }, + { + "id": 425101034640013, + "event-id": 424998308080011, + "event-name": "A Murray vs R Federer", + "market-id": 424998308230011, + "market-name": "Moneyline", + "runner-id": 424998308260011, + "runner-name": "A Murray", + "sport-id": 9, + "exchange-type": "back-lay", + "side": "back", + "odds": 4.6, + "odds-type": "DECIMAL", + "decimal-odds": 4.6, + "stake": 12, + "remaining": 12, + "potential-profit": 43.2, + "remaining-potential-profit": 43.2, + "commission-type": "NET_WIN", + "originator-commission-rate": 0.035, + "acceptor-commission-rate": 0.035, + "commission-reserve": 0, + "currency": "EUR", + "created-at": "2017-03-02T15:02:54.894Z", + "status": "open", + "in-play": false, + "keep-in-play": false + } + ], + "offset": 0, + "total": 2, + "per-page": 500 + }, + "current_bets": { + "offset": 0, + "per-page": 2000, + "total": 1, + "language": "en", + "currency": "EUR", + "odds-type": "DECIMAL", + "markets": [ + { + "id": 919619528410042, + "event-id": 919619528170041, + "event-name": "Manchester United vs Juventus", + "start-time": "2018-10-23T19:00:00.000Z", + "sport-id": 15, + "name": "Match Odds", + "selections": [ + { + "runner-id": 919619528480041, + "runner-name": "Manchester United", + "side": "back", + "odds": 3.52, + "stake": 5.0, + "liability": 5.0, + "potential-profit": 12.6, + "bets": [ + { + "id": 938218459630248, + "odds": 3.52, + "stake": 5.0, + "in-play": false, + "matched-time": "2018-10-17T12:18:37.393Z", + "adjusted": false, + "potential-profit": 12.6, + "liability": 5.0 + } + ] + } + ], + "liability": 5.0, + "potential-profit": 12.6 + } + ], + "liability": 5.0, + "potential-profit": 12.6 + }, + "settled_bets": { + "offset": 0, + "per-page": 20, + "total": 2, + "language": "en", + "currency": "EUR", + "odds-type": "DECIMAL", + "profit-and-loss": -8.99998, + "commission": 0.1349997, + "net-profit-and-loss": -9.1349797, + "markets": [ + { + "id": 881454180790042, + "name": "Winner", + "event-id": 881453329700042, + "event-name": "Tuesday Carabao Cup Treble 6", + "sport-id": 15, + "start-time": "2018-08-14T18:45:00.000Z", + "settled-time": "2018-08-14T20:45:19.692Z", + "profit-and-loss": -6.99999, + "commission-type": "VOLUME", + "commission": 0.10499985, + "net-profit-and-loss": -7.10498985, + "selections": [ + { + "runner-id": 881454181050042, + "runner-name": "Aston Villa/Leeds/Middlesbrough", + "side": "BACK", + "odds": 4.2, + "stake": 6.99999, + "profit-and-loss": -6.99999, + "commission": 0.10499985, + "net-profit-and-loss": -7.10498985, + "bets": [ + { + "id": 882883452530246, + "offer-id": 882883452530046, + "odds": 4.2, + "stake": 3.0, + "adjusted": false, + "originator": false, + "in-play": false, + "submitted-time": "2018-08-14T11:13:36.683Z", + "matched-time": "2018-08-14T11:13:36.683Z", + "settled-time": "2018-08-14T20:45:19.692Z", + "result": "LOSE", + "profit-and-loss": -3.0, + "commission-type": "VOLUME", + "commission": 0.045, + "net-profit-and-loss": -3.045 + }, + { + "id": 882883958600246, + "offer-id": 882883958600046, + "odds": 4.2, + "stake": 3.99999, + "adjusted": false, + "originator": false, + "in-play": false, + "submitted-time": "2018-08-14T11:14:27.290Z", + "matched-time": "2018-08-14T11:14:27.290Z", + "settled-time": "2018-08-14T20:45:19.692Z", + "result": "LOSE", + "profit-and-loss": -3.99999, + "commission-type": "VOLUME", + "commission": 0.05999985, + "net-profit-and-loss": -4.05998985 + } + ] + } + ] + } + ] + }, + "balance": { + "id": 12345, + "balance": 0, + "exposure": 0, + "commission-reserve": 0, + "free-funds": 0 + }, + "account": { + "id": 12345, + "username": "testuser", + "status": "ACTIVE", + "commission-type": "VOLUME", + "currency": "EUR", + "country": "IE", + "balance": 0, + "exposure": 0, + "commission-reserve": 0, + "free-funds": 0, + "language": "en", + "exchange-type": "back-lay", + "odds-type": "US", + "show-bet-confirmation": false, + "show-position": true, + "show-odds-rounding-message": true, + "bet-slip-pinned": true, + "roles": [ + "USER" + ] + }, + "heartbeat": { + "action-performed": "HEARTBEAT_ACTIVATED", + "actual-timeout": 20, + "timeout-time": "2018-06-27T13:57:17.354Z" + }, + "heartbeat_off": { + "action-performed": "HEARTBEAT_TERMINATED" + } +} diff --git a/tests/test_init.py b/tests/test_init.py index 775d89a..740c21a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -42,7 +42,8 @@ def test_it_writes_every_venue_asked_for_readable_by_the_owner_only(tmp_path: Pa "n", # Limitless "n", # SX Bet "n", # Betfair - "n"], # Smarkets + "n", # Smarkets + "n"], # Matchbook secrets=[KEY, "us-secret"], ) assert run(env, p) == 0 @@ -64,7 +65,7 @@ def test_wrong_values_are_asked_again_with_the_reason(tmp_path: Path): p, said = scripted( ["y", "", "kid-1", str(tmp_path / "missing.pem"), str(not_pem), str(pem), "staging", "demo", "y", "0x1234", WALLET, "7", "1", - "n", "n", "n", "n", "n", "n", "n", "n"], + "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -81,7 +82,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) env = tmp_path / ".env" env.write_text("# mine\nSOMETHING_ELSE=keep\nPOLYMARKET_US_KEY_ID=old\n") os.chmod(env, 0o644) - p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) + p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) assert run(env, p) == 0 text = env.read_text() assert "# mine" in text and "SOMETHING_ELSE=keep" in text @@ -91,7 +92,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) def test_saying_no_to_everything_writes_nothing(tmp_path: Path): - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) assert run(tmp_path / ".env", p) == 0 assert not (tmp_path / ".env").exists() and any("Nothing changed." in line for line in said) @@ -104,7 +105,7 @@ def test_merge_quotes_values_with_spaces_and_updates_in_place(): @pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) + p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) assert run(tmp_path / ".env", p) == 0 assert (tmp_path / ".gitignore").read_text() == ".env\n" ignored = subprocess.run(["git", "check-ignore", "-q", ".env"], cwd=tmp_path) @@ -113,7 +114,7 @@ def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): def test_opinion_takes_a_key_it_is_given(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["OPINION_PRIVATE_KEY"] == KEY and values["OPINION_API_KEY"] == "opinion-api-key" @@ -122,7 +123,7 @@ def test_opinion_takes_a_key_it_is_given(tmp_path: Path): def test_opinion_with_an_api_key_needs_no_private_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"OPINION_API_KEY": "opinion-api-key"} assert load_credentials({}, dotenv=env, redact_logs=False)["opinion"] is None, "not enough to trade" @@ -135,7 +136,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch signed_with: list[str] = [] monkeypatch.setattr(opinion, "create_api_key", lambda key: signed_with.append(key) or "created-key") - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) assert run(tmp_path / ".env", p) == 0 assert signed_with == [KEY], "creating a key needs the private key: an empty one is asked again" assert read_dotenv(tmp_path / ".env")["OPINION_API_KEY"] == "created-key" @@ -144,7 +145,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY and values["HYPERLIQUID_ACCOUNT_ADDRESS"] == WALLET @@ -156,7 +157,7 @@ def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY @@ -165,7 +166,7 @@ def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_PRIVATE_KEY"] == KEY and values["PREDICT_FUN_ACCOUNT_ADDRESS"] == WALLET @@ -178,7 +179,7 @@ def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"PREDICT_FUN_API_KEY": "pred_sk_test"} text = " ".join(said) @@ -190,7 +191,7 @@ def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_TESTNET"] == "1" and "PREDICT_FUN_API_KEY" not in values @@ -201,7 +202,7 @@ def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): env = tmp_path / ".env" secret = "c2VjcmV0LWJ5dGVz" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n"], secrets=[KEY[2:], secret]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) assert run(env, p) == 0 values = read_dotenv(env) assert values["LIMITLESS_PRIVATE_KEY"] == KEY and values["LIMITLESS_API_TOKEN_ID"] == "token-123" @@ -212,7 +213,7 @@ def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n"], secrets=["sx-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["SX_BET_PRIVATE_KEY"] == KEY and values["SX_BET_API_KEY"] == "sx-api-key" @@ -223,7 +224,7 @@ def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n"], secrets=["sx-api-key", ""]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key", "SX_BET_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said), "a key pasted as the address is refused" @@ -234,7 +235,7 @@ def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): def test_sx_bet_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n"], secrets=["sx-api-key", ""]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key"} loaded = load_credentials({}, dotenv=env, redact_logs=False)["sx_bet"] diff --git a/tests/test_matchbook.py b/tests/test_matchbook.py new file mode 100644 index 0000000..eb6f2bd --- /dev/null +++ b/tests/test_matchbook.py @@ -0,0 +1,524 @@ +"""Matchbook: ids, back/lay read on YES, catalog walk, status, books, fees, login. + +The market-data samples under `tests/samples/matchbook_*.json` were recorded +from api.matchbook.com anonymously on 2026-10-23, then trimmed: three events +kept (a race and two football fixtures kicking off at the same second), a few +markets each, the race's runners cut to four and one withdrawn, the sports +list to three, and `total` set to what was kept. The login sample is the +example in Matchbook's API reference. Every request goes to an +`httpx.MockTransport`; nothing here reaches Matchbook. +""" +from __future__ import annotations + +import json +import re + +import httpx +import pytest + +import synpath +from synpath import AuthenticationError, BadRequest, MarketNotFound, NotSupported +from synpath.matchbook import ( + DEFAULT_COMMISSION, SESSION_REFRESH_AFTER, Matchbook, MatchbookSession, fee_schedule_of, iso_ms, + normalize_event, normalize_market, normalize_order_book, parse_market_id, parse_runner_id, plain_line, + runner_native_id, start_s, status_of, +) +from synpath.trading.credentials import CredentialsMissing, load_matchbook + +from conftest import load + +EVENTS = load("matchbook_events.json") +SPORTS = load("matchbook_sports.json") +PRICES = load("matchbook_prices.json") +CLOSED = load("matchbook_closed.json") +ERRORS = load("matchbook_errors.json") +LOGIN = load("matchbook_trading.json")["login"] +LOGIN_REFUSED = load("matchbook_trading.json")["login_refused"] +LOGIN_MFA = load("matchbook_trading.json")["login_mfa"] + +RACE, ARSENAL_EVERTON, COVENTRY_FULHAM = EVENTS["events"] +MATCH_ODDS, TOTAL, WHOLE_HANDICAP, ASIAN_HANDICAP, BTTS = ARSENAL_EVERTON["markets"] +WIN = RACE["markets"][0] +ARSENAL = MATCH_ODDS["runners"][0] +ARSENAL_ID = "matchbook:34569494493900023-34569495835700023-34569495836701023" +MATCH_ODDS_ID = "matchbook:34569494493900023-34569495835700023" +CLOSED_EVENT = CLOSED["event"] +CLOSED_MARKET = CLOSED["markets"]["markets"][0] +CLOSED_RUNNER = CLOSED["runners"]["runners"][0] +CLOSED_ID = f"matchbook:{CLOSED_EVENT['id']}-{CLOSED_MARKET['id']}-{CLOSED_RUNNER['id']}" + + +@pytest.fixture(autouse=True) +def no_ambient_credentials(monkeypatch, tmp_path): + """No test may pick up a real login from the environment or a `.env`.""" + for name in ("MATCHBOOK_USERNAME", "MATCHBOOK_PASSWORD"): + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + + +class Api: + """api.matchbook.com, answered from the samples. Records every request.""" + + def __init__(self, overrides: dict | None = None): + self.requests: list[httpx.Request] = [] + self.overrides = overrides or {} + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path, params = request.url.path, request.url.params + for pattern, answer in self.overrides.items(): + if re.fullmatch(pattern, path): + return answer(request) if callable(answer) else answer + if path == "/bpapi/rest/security/session" and request.method == "POST": + return httpx.Response(200, json=LOGIN) + if path == "/edge/rest/lookups/sports": + return httpx.Response(200, json=SPORTS) + if path == "/edge/rest/events": + return httpx.Response(200, json=self.listing(params)) + if m := re.fullmatch(r"/edge/rest/events/(\d+)", path): + event = self.event(m.group(1)) + return httpx.Response(200, json=event) if event else httpx.Response(404, json=ERRORS["not_found"]) + if m := re.fullmatch(r"/edge/rest/events/(\d+)/markets/(\d+)", path): + market = self.market(m.group(1), m.group(2)) + return httpx.Response(200, json=market) if market else httpx.Response(404, json=ERRORS["not_found"]) + if m := re.fullmatch(r"/edge/rest/events/(\d+)/markets/(\d+)/runners", path): + if (m.group(1), m.group(2)) == (str(CLOSED_EVENT["id"]), str(CLOSED_MARKET["id"])): + return httpx.Response(200, json=CLOSED["runners"]) + return httpx.Response(404, json=ERRORS["not_found"]) + if m := re.fullmatch(r"/edge/rest/events/(\d+)/markets/(\d+)/runners/(\d+)/prices", path): + if f"matchbook:{m.group(1)}-{m.group(2)}-{m.group(3)}" == ARSENAL_ID: + return httpx.Response(200, json=PRICES) + return httpx.Response(404, json=ERRORS["not_found"]) + return httpx.Response(404, json=ERRORS["not_found"]) + + @staticmethod + def event(event_id: str) -> dict | None: + if event_id == str(CLOSED_EVENT["id"]): + return CLOSED_EVENT + return next((e for e in EVENTS["events"] if str(e["id"]) == event_id), None) + + def market(self, event_id: str, market_id: str) -> dict | None: + if (event_id, market_id) == (str(CLOSED_EVENT["id"]), str(CLOSED_MARKET["id"])): + return CLOSED_MARKET + event = self.event(event_id) or {} + return next((m for m in event.get("markets") or [] if str(m["id"]) == market_id), None) + + @staticmethod + def listing(params) -> dict: + """The events listing as Matchbook pages it: ordered by start, `after` + in whole seconds and inclusive, then `offset` and `per-page`.""" + rows = list(EVENTS["events"]) + [CLOSED_EVENT] + if params.get("ids"): + wanted = params["ids"].split(",") + rows = [e for e in rows if str(e["id"]) in wanted] + states = (params.get("states") or "open,suspended,closed,graded").split(",") + rows = [e for e in rows if e["status"] in states] + if params.get("sport-ids"): + rows = [e for e in rows if str(e["sport-id"]) in params["sport-ids"].split(",")] + if params.get("after"): + rows = [e for e in rows if start_s(e) >= int(params["after"])] + rows.sort(key=lambda e: iso_ms(e["start"])) + offset, size = int(params.get("offset") or 0), int(params.get("per-page") or 20) + return {"offset": offset, "per-page": size, "total": len(rows), "events": rows[offset:offset + size]} + + def paths(self) -> list[str]: + return [r.url.path for r in self.requests] + + def client(self) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(self.handler)) + + +def venue(api: Api | None = None, **kwargs) -> tuple[Matchbook, Api]: + api = api or Api() + return Matchbook(limiter=None, navigation_limiter=None, client=api.client(), **kwargs), api + + +def session(api: Api, **kwargs) -> MatchbookSession: + return MatchbookSession(username="jbloggs", password="hunter2", client=api.client(), **kwargs) + + +class TestIds: + def test_round_trip(self): + native = runner_native_id(34569494493900023, 34569495835700023, 34569495836701023) + assert f"matchbook:{native}" == ARSENAL_ID + assert parse_runner_id(native) == ("34569494493900023", "34569495835700023", "34569495836701023") + assert parse_market_id(native) == parse_market_id("34569494493900023-34569495835700023") + + @pytest.mark.parametrize("bad", ["34569494493900023", "1-2", "1-2-x", "a-b-c", "1-2-3-4"]) + def test_a_bad_runner_id_is_refused(self, bad): + with pytest.raises(BadRequest): + parse_runner_id(bad) + + def test_synpath_ids_route_to_matchbook(self): + assert synpath.ids.venue_of(ARSENAL_ID) == "matchbook" + assert "matchbook" in synpath.ids.VENUES and synpath.exchanges["matchbook"] is Matchbook + + +class TestBackAndLayReadOnYes: + """A `back` price is one a taker backs at, a resting lay: a YES ask. A + `lay` price is one a taker lays at, a resting back: a YES bid.""" + + def test_lay_prices_are_bids_and_back_prices_asks(self): + book = normalize_order_book(PRICES["prices"], market_id=ARSENAL_ID) + assert book.bids[0].price == pytest.approx(1 / 1.50) and book.asks[0].price == pytest.approx(1 / 1.49) + assert [round(1 / level.price, 2) for level in book.bids] == [1.5, 1.51, 1.52, 1.53, 1.54] + assert [round(1 / level.price, 2) for level in book.asks] == [1.49, 1.48, 1.47, 1.46, 1.45] + + def test_sizes_are_the_backers_stake_times_the_odds(self): + book = normalize_order_book(PRICES["prices"], market_id=ARSENAL_ID) + assert book.bids[0].size == pytest.approx(1388.11838 * 1.5) + assert book.asks[0].size == pytest.approx(4394.55779 * 1.49) + + def test_no_recorded_book_is_crossed(self): + for event in EVENTS["events"]: + for market in event["markets"]: + for runner in market["runners"]: + book = normalize_order_book(runner["prices"], market_id="matchbook:x") + if book.bids and book.asks: + assert book.bids[0].price < book.asks[0].price, runner["name"] + + def test_the_venues_own_rows_and_currency_are_kept(self): + book = normalize_order_book(PRICES["prices"], market_id=ARSENAL_ID) + assert book.info["back"][0] == [1.49, 4394.55779] and book.info["lay"][0] == [1.5, 1388.11838] + assert book.info["currency"] == "GBP" and book.book_model == "shared_complement" + assert book.depth_scope == "top_n" + + def test_the_no_side_is_the_mirror(self): + no = normalize_order_book(PRICES["prices"], market_id=ARSENAL_ID, side="no") + assert no.derived and no.bids[0].price == pytest.approx(1 - 1 / 1.49) and no.asks[0].price == pytest.approx(1 / 3) + with pytest.raises(BadRequest): + normalize_order_book(PRICES["prices"], market_id=ARSENAL_ID, side="maybe") # type: ignore[arg-type] + + def test_depth_cuts_each_side(self): + book = normalize_order_book(PRICES["prices"], market_id=ARSENAL_ID, depth=2) + assert len(book.bids) == len(book.asks) == 2 + + def test_an_empty_runner(self): + withdrawn = WIN["runners"][-1] + book = normalize_order_book(withdrawn["prices"], market_id="matchbook:x") + assert book.bids == [] and book.asks == [] + + +class TestCatalogMapping: + def arsenal(self): + return normalize_market(ARSENAL_EVERTON, MATCH_ODDS, ARSENAL) + + def test_each_runner_is_a_market_and_the_matchbook_market_its_event(self): + market = self.arsenal() + assert market.id == ARSENAL_ID and market.event_id == MATCH_ODDS_ID + assert market.title == "Arsenal vs Everton — Match Odds — Arsenal" + assert market.yes.label == "Arsenal" and market.no.label == "Not Arsenal" + assert market.info["matchbook_event"]["name"] == "Arsenal vs Everton" + assert "markets" not in market.info["matchbook_event"] and "prices" not in market.info["runner"] + + def test_tags_are_sport_competition_and_country(self): + assert self.arsenal().tags == ["soccer", "england premier league", "england"] + assert self.arsenal().category == "soccer" + race = normalize_market(RACE, WIN, WIN["runners"][0]) + assert race.tags == ["horse racing", "ireland", "dundalk"] + + def test_quotes_and_volume(self): + market = self.arsenal() + assert market.yes.quote.bid == pytest.approx(1 / 1.5) and market.yes.quote.ask == pytest.approx(1 / 1.49) + assert market.no.quote.bid == pytest.approx(1 - 1 / 1.49) and market.yes.quote.last is None + assert market.stats.volume_total == pytest.approx(6330.96959) and market.stats.volume_unit == "collateral" + assert market.info["currency"] == "GBP" and market.info["in_play"] is False + + def test_a_market_that_turns_in_play_has_no_known_close(self): + assert self.arsenal().close_timestamp is None + handicap = normalize_market(ARSENAL_EVERTON, WHOLE_HANDICAP, WHOLE_HANDICAP["runners"][0]) + assert handicap.close_datetime == "2026-10-24T14:00:00Z" + + def test_lines_that_can_push_or_half_win_are_not_binary(self): + assert plain_line(MATCH_ODDS) and plain_line(TOTAL) and plain_line(BTTS) + assert not plain_line(WHOLE_HANDICAP) and not plain_line(ASIAN_HANDICAP) + assert normalize_market(ARSENAL_EVERTON, TOTAL, TOTAL["runners"][0]).market_type == "binary" + assert normalize_market(ARSENAL_EVERTON, ASIAN_HANDICAP, ASIAN_HANDICAP["runners"][0]).market_type == "unknown" + assert normalize_market(ARSENAL_EVERTON, TOTAL, TOTAL["runners"][0]).info["line"] == 2.5 + + def test_the_event_holds_every_runner_and_is_exclusive(self): + event = normalize_event(ARSENAL_EVERTON, MATCH_ODDS) + assert event.id == MATCH_ODDS_ID and len(event.markets) == 3 + assert {m.outcome_label for m in event.markets} == {"Arsenal", "Everton", "Draw"} + assert event.mutually_exclusive is True and event.status == "open" + assert normalize_event(ARSENAL_EVERTON, WHOLE_HANDICAP).mutually_exclusive is None + + def test_serializes(self): + json.dumps(self.arsenal().model_dump()) + + +class TestStatus: + @pytest.mark.parametrize("event,market,runner,withdrawn,expected", [ + ("open", "open", "open", False, ("open", True)), + ("open", "suspended", "open", False, ("open", False)), + ("open", "open", "suspended", False, ("open", False)), + ("closed", "closed", "closed", False, ("closed", False)), + ("graded", "closed", "closed", False, ("settled", False)), + ("open", "open", "paid", True, ("settled", False)), + ("open", "open", "closed", True, ("settled", False)), + ]) + def test_status(self, event, market, runner, withdrawn, expected): + assert status_of(event, market, runner, withdrawn) == expected + + def test_a_withdrawn_runner_is_settled_void(self): + withdrawn = normalize_market(RACE, WIN, WIN["runners"][-1]) + assert withdrawn.status == "settled" and not withdrawn.active and withdrawn.info["result"] == "void" + assert normalize_market(RACE, WIN, WIN["runners"][0]).status == "open" + + def test_a_closed_market_is_closed_with_no_result(self): + market = normalize_market(CLOSED_EVENT, CLOSED_MARKET, CLOSED_RUNNER) + assert market.status == "closed" and market.info["result"] is None + + +class TestAdapter: + def test_listing_reads_events_with_their_markets_and_prices_in_one_request(self): + mb, api = venue() + page = mb.fetch_markets(limit=5) + assert api.paths() == ["/edge/rest/events"] + params = api.requests[0].url.params + assert params["states"] == "open,suspended" and params["include-prices"] == "true" + assert params["odds-type"] == "DECIMAL" and params["exchange-type"] == "back-lay" + assert params["price-depth"] == "3" and "after" not in params + # The race starts first; its withdrawn runner is left out. + assert [m.outcome_label for m in page] == ["14 Eichan San", "3 Mehman", "11 Iff In Doubt", "5 Faoladh", + "Arsenal"] + + def test_every_request_carries_a_real_user_agent_and_no_session(self): + mb, api = venue() + mb.fetch_markets(limit=2) + for request in api.requests: + assert request.headers["User-Agent"].startswith("synpath/") + assert "session-token" not in request.headers + + def test_the_cursor_resumes_mid_market_without_repeats_or_gaps(self): + mb, _ = venue() + whole = [m.id for m in mb.fetch_markets(limit=200)] + assert len(whole) == 4 + 11 + 5 + seen, cursor = [], None + for size in (3, 2, 4, 1, 7, 50): + page = mb.fetch_markets(limit=size, cursor=cursor) + seen += [m.id for m in page] + cursor = page.next_cursor + if cursor is None: + break + assert seen == whole + + def test_pages_of_events_anchor_on_start_times_and_page_through_ties(self, monkeypatch): + """Two fixtures kick off at the same second. With two events a + request, the second page anchors on the kick-off and skips the one + fixture at it already read.""" + monkeypatch.setattr("synpath.matchbook.EVENT_PAGE", 2) + mb, api = venue() + whole = [m.id for m in mb.fetch_markets(limit=200)] + assert len(whole) == 20 and len(set(whole)) == 20 + kick_off = str(start_s(ARSENAL_EVERTON)) + assert start_s(ARSENAL_EVERTON) == start_s(COVENTRY_FULHAM) + assert [(r.url.params.get("after"), r.url.params["offset"]) for r in api.requests] == [(None, "0"), (kick_off, "1")] + + def test_a_cursor_names_the_events_own_start(self): + mb, _ = venue() + page = mb.fetch_markets(limit=5) + assert page.next_cursor == f"{start_s(ARSENAL_EVERTON)}|0|0|1" + + def test_the_end_of_the_listing_has_no_cursor(self): + mb, _ = venue() + assert mb.fetch_markets(limit=200).next_cursor is None + + def test_sport_names_are_resolved_through_the_navigation_group(self): + mb, api = venue() + page = mb.fetch_markets(sport="soccer", limit=100) + assert api.paths() == ["/edge/rest/lookups/sports", "/edge/rest/events"] + assert api.requests[1].url.params["sport-ids"] == "15" + assert {m.category for m in page} == {"soccer"} + with pytest.raises(BadRequest, match="no sport named Curling"): + mb.fetch_markets(sport="Curling") + + def test_market_types_filter_the_markets(self): + mb, _ = venue() + page = mb.fetch_markets(market_type="total", limit=100) + assert {m.info["market_type"] for m in page} == {"total"} and len(page) == 4 + + def test_search_sort_and_closed_listings_are_refused(self): + mb, api = venue() + with pytest.raises(NotSupported, match="no text search"): + mb.fetch_markets(query="arsenal") + with pytest.raises(NotSupported): + mb.fetch_markets(sort="volume") + with pytest.raises(NotSupported, match="open events only"): + mb.fetch_markets(status="settled") + with pytest.raises(BadRequest): + mb.fetch_markets(cursor="nonsense") + assert api.requests == [] + + def test_fetch_events_groups_runners_under_their_market(self): + mb, _ = venue() + page = mb.fetch_events(limit=3) + assert [e.title for e in page] == ["17:45 Dundalk — WIN", "Arsenal vs Everton — Match Odds", + "Arsenal vs Everton — Total"] + assert len(page[1].markets) == 3 and page.next_cursor == f"{start_s(ARSENAL_EVERTON)}|0|2|0" + rest = mb.fetch_events(limit=50, cursor=page.next_cursor) + assert rest[0].title == "Arsenal vs Everton — Handicap" and len(page) + len(rest) == 8 + + def test_fetch_event_reads_an_open_market(self): + mb, api = venue() + event = mb.fetch_event(MATCH_ODDS_ID) + assert event.id == MATCH_ODDS_ID and len(event.markets) == 3 + assert api.paths() == ["/edge/rest/events"] and api.requests[0].url.params["ids"] == "34569494493900023" + + def test_fetch_event_reads_a_closed_market_and_its_runners(self): + mb, api = venue() + event = mb.fetch_event(f"matchbook:{CLOSED_EVENT['id']}-{CLOSED_MARKET['id']}") + assert event.status == "closed" and len(event.markets) == 3 + assert api.paths()[-1].endswith("/runners") + assert api.requests[-1].url.params["states"] == "open,suspended,closed,paid" + + def test_fetch_markets_by_ids_keeps_the_order_and_drops_the_unknown(self): + mb, api = venue() + handicap = f"matchbook:{ARSENAL_EVERTON['id']}-{WHOLE_HANDICAP['id']}-{WHOLE_HANDICAP['runners'][1]['id']}" + found = mb.fetch_markets_by_ids([CLOSED_ID, handicap, ARSENAL_ID, "matchbook:1-2-3", + f"matchbook:{ARSENAL_EVERTON['id']}-{MATCH_ODDS['id']}-1"]) + assert [m.id for m in found] == [CLOSED_ID, handicap, ARSENAL_ID] + assert found[0].status == "closed" and found[1].market_type == "unknown" + assert api.paths()[0] == "/edge/rest/events" + assert api.requests[0].url.params["states"] == "open,suspended,closed,graded" + + def test_fetch_market_not_found(self): + mb, _ = venue() + with pytest.raises(MarketNotFound): + mb.fetch_market("matchbook:1-2-3") + + def test_fetch_order_book_reads_the_runners_prices_alone(self): + mb, api = venue() + book = mb.fetch_order_book(ARSENAL_ID, depth=5) + assert book.market_id == ARSENAL_ID and len(book.bids) == 5 + assert api.paths() == ["/edge/rest/events/34569494493900023/markets/34569495835700023/runners/" + "34569495836701023/prices"] + assert api.requests[0].url.params["depth"] == "5" + with pytest.raises(MarketNotFound): + mb.fetch_order_book("matchbook:1-2-3") + + def test_fetch_order_books_for_runners_and_whole_markets(self): + mb, api = venue() + books = mb.fetch_order_books([ARSENAL_ID, f"matchbook:{RACE['id']}-{WIN['id']}"]) + assert api.paths() == ["/edge/rest/events"] + assert ARSENAL_ID in books and len(books) == 1 + len(WIN["runners"]) + assert books[ARSENAL_ID].info["status"] == "open" + + def test_a_reason_from_matchbook_reaches_the_error(self): + refused = {"errors": [{"messages": ["Invalid sport-ids."], "codes": [None]}]} + mb, _ = venue(Api({r"/edge/rest/events": httpx.Response(400, json=refused)})) + with pytest.raises(BadRequest, match="matchbook: Invalid sport-ids"): + mb.fetch_markets(sport="15") + with pytest.raises(MarketNotFound): + venue()[0].fetch_event("matchbook:1-2") + + def test_no_trades_and_no_candles(self): + mb, api = venue() + with pytest.raises(NotSupported, match="no individual trades"): + mb.fetch_trades(ARSENAL_ID) + with pytest.raises(NotSupported): + mb.fetch_ohlcv(ARSENAL_ID) + assert api.requests == [] + + def test_sports(self): + mb, _ = venue() + assert [s["name"] for s in mb.fetch_sports()] == ["Horse Racing", "Soccer", "Tennis"] + + def test_a_currency_asked_for_is_sent(self): + mb, api = venue(currency="EUR") + mb.fetch_markets(limit=1) + assert api.requests[0].url.params["currency"] == "EUR" + + +class TestFees: + def test_commission_on_net_market_winnings(self): + fee = fee_schedule_of("34569494493900023-34569495835700023") + assert fee.fee_type == "net_market_winnings" and fee.taker_rate == fee.maker_rate == DEFAULT_COMMISSION == 0.02 + assert fee.estimate(price=0.5, contracts=100) == pytest.approx(1.0) + + def test_the_adapter_names_the_matchbook_market_and_reads_nothing(self): + mb, api = venue(commission=0.04) + fee = mb.fetch_fee_schedule(ARSENAL_ID) + assert fee.scope_id == "34569494493900023-34569495835700023" and fee.taker_rate == 0.04 + assert api.requests == [] + + +class TestSession: + def test_login_posts_json_and_the_token_rides_as_session_token(self): + api = Api() + mb, _ = venue(api, session=session(api)) + mb.fetch_order_book(ARSENAL_ID) + login, prices = api.requests + assert login.method == "POST" and login.url.path == "/bpapi/rest/security/session" + assert json.loads(login.content) == {"username": "jbloggs", "password": "hunter2"} + assert prices.headers["session-token"] == LOGIN["session-token"] + + def test_an_old_session_is_replaced(self): + api, now = Api(), [0.0] + s = session(api, clock=lambda: now[0]) + s.token() + now[0] += SESSION_REFRESH_AFTER - 1 + s.token() + now[0] += 2 + s.token() + assert api.paths() == ["/bpapi/rest/security/session"] * 2 + assert s.account["currency"] == "GBP" + + def test_a_refused_token_logs_in_again_and_retries_once(self): + seen: list[str] = [] + + def prices(request): + seen.append(request.headers.get("session-token", "")) + return httpx.Response(401, json={"errors": [{"messages": ["Please login to continue"]}]}) \ + if len(seen) == 1 else httpx.Response(200, json=PRICES) + + api = Api({r"/edge/rest/events/\d+/markets/\d+/runners/\d+/prices": prices}) + mb, _ = venue(api, session=session(api, session_token="OLD")) + mb.fetch_order_book(ARSENAL_ID) + assert seen == ["OLD", LOGIN["session-token"]] + + def test_two_factor_login_is_refused_clearly(self): + api = Api({r"/bpapi/rest/security/session": httpx.Response(400, json=LOGIN_MFA)}) + with pytest.raises(AuthenticationError, match="two-factor"): + session(api).token() + + def test_bad_credentials_say_why(self): + api = Api({r"/bpapi/rest/security/session": httpx.Response(400, json=LOGIN_REFUSED)}) + with pytest.raises(AuthenticationError, match="Username or password are incorrect"): + session(api).token() + + def test_the_password_and_token_never_show(self): + s = session(Api()) + s.token() + assert "hunter2" not in repr(s) and LOGIN["session-token"] not in repr(s) + + +class TestCredentials: + def test_loading(self): + assert load_matchbook({}) is None + creds = load_matchbook({"MATCHBOOK_USERNAME": "jbloggs", "MATCHBOOK_PASSWORD": "pw-123456"}) + assert creds.username == "jbloggs" and "pw-123456" not in repr(creds) and creds.secrets == ["pw-123456"] + with pytest.raises(CredentialsMissing, match="both"): + load_matchbook({"MATCHBOOK_USERNAME": "jbloggs"}) + + def test_the_read_adapter_logs_in_only_when_asked(self, monkeypatch): + monkeypatch.setenv("MATCHBOOK_USERNAME", "jbloggs") + monkeypatch.setenv("MATCHBOOK_PASSWORD", "pw") + assert Matchbook(limiter=None).session is None + assert Matchbook(limiter=None, login=True).session is not None + assert Matchbook(limiter=None, session_token="tok").session is not None + + +class TestCapabilities: + def test_declared_honestly(self): + has = Matchbook.has + assert has["fetch_markets"] is True and has["fetch_order_books"] is True + assert has["fetch_trades"] is False and has["fetch_ohlcv"] is False + assert has["search"] is False and has["sort"] is False + + def test_order_entry_is_offered(self): + from synpath.trading.matchbook import MatchbookTrading + + assert synpath.MatchbookTrading is MatchbookTrading + with pytest.raises(CredentialsMissing, match="MATCHBOOK_USERNAME"): + synpath.Client().trading("matchbook") diff --git a/tests/test_matchbook_live.py b/tests/test_matchbook_live.py new file mode 100644 index 0000000..9a9262b --- /dev/null +++ b/tests/test_matchbook_live.py @@ -0,0 +1,45 @@ +"""Matchbook against the real venue: run with `pytest -m live`. No account needed. + +Kept light: a handful of reads, inside the events group's 700 a minute.""" +from __future__ import annotations + +import pytest + +from synpath import Matchbook + +pytestmark = pytest.mark.live + + +@pytest.fixture(scope="module") +def venue(): + with Matchbook() as mb: + yield mb + + +@pytest.fixture(scope="module") +def markets(venue): + return venue.fetch_markets(sport="Soccer", limit=20) + + +def test_the_catalog_is_open_and_named(markets): + assert markets and all(m.status == "open" and m.yes.label for m in markets) + assert all(m.id.startswith("matchbook:") and m.event_id for m in markets) + + +def test_books_are_yes_and_never_crossed(venue, markets): + books = venue.fetch_order_books([m.id for m in markets]) + assert books + for book in books.values(): + if book.bids and book.asks: + assert book.bids[0].price < book.asks[0].price + assert all(0 < level.price < 1 and level.size > 0 for level in book.bids + book.asks) + + +def test_one_runners_prices(venue, markets): + book = venue.fetch_order_book(markets[0].id, depth=5) + assert book.market_id == markets[0].id and len(book.bids) <= 5 + + +def test_paging_neither_repeats_nor_drops(venue, markets): + following = venue.fetch_markets(sport="Soccer", limit=20, cursor=markets.next_cursor) + assert not {m.id for m in markets} & {m.id for m in following} diff --git a/tests/test_matchbook_trading.py b/tests/test_matchbook_trading.py new file mode 100644 index 0000000..fc84343 --- /dev/null +++ b/tests/test_matchbook_trading.py @@ -0,0 +1,444 @@ +"""Matchbook order entry: offers built from orders, Matchbook's records read on +the YES leg, and the adapter against a scripted venue. Records are the +examples in Matchbook's API reference (betting needs an account); the login +example had a trailing comma removed and the settled-bets example its `...` +placeholder. Every request goes to an `httpx.MockTransport`.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +import httpx +import pytest + +from synpath.trading.credentials import MatchbookCredentials +from synpath.trading.errors import CredentialsMissing, InsufficientFunds, InvalidOrder, OrderRejected +from synpath.trading.matchbook import ( + MatchbookTrading, build_offer, fills_of_offer, odds_for, order_of_offer, positions_of_bets, settlements_of, + stake_for, +) +from synpath.trading.types import ( + EditRequest, OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce, +) + +from conftest import load + +S = load("matchbook_trading.json") +MAN_UTD = "matchbook:410343160570009-410343161840009-401525949430009" +OFFER_ID = "413177013410013" + + +def request(**changes) -> OrderRequest: + base = {"market_id": MAN_UTD, "side": Side.BUY, "quantity": Decimal("12"), "price": Decimal("0.4166666667")} + return OrderRequest(**{**base, **changes}) + + +class Venue: + """Answers requests by `METHOD /path`, and records them.""" + + def __init__(self, **answers): + self.answers = {"POST /bpapi/rest/security/session": S["login"], **answers} + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + answer = self.answers.get(f"{req.method} {req.url.path}") + if answer is None: + return httpx.Response(404, json={"errors": [{"messages": ["Resource not found."], "codes": [None]}]}) + if callable(answer): + answer = answer(req) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + def sent(self, method: str, path: str) -> list[httpx.Request]: + return [r for r in self.requests if r.method == method and r.url.path == path] + + +class Catalog: + def fetch_fee_schedule(self, market_id): + from synpath.matchbook import fee_schedule_of + return fee_schedule_of(market_id) + + def close(self): + pass + + +def adapter(venue: Venue) -> MatchbookTrading: + creds = MatchbookCredentials(username="jbloggs", password="verysecurepassword") + return MatchbookTrading( + creds, catalog=Catalog(), reports_limiter=None, + client=httpx.AsyncClient(transport=httpx.MockTransport(venue)), + session_client=httpx.Client(transport=httpx.MockTransport(venue)), + ) + + +def run(coro): + return asyncio.run(coro) + + +def offer(row: dict, **changes) -> dict: + return {**row, **changes} + + +class TestOffers: + def test_a_buy_backs_at_the_odds_of_its_price(self): + assert build_offer(request()) == { + "runner-id": 401525949430009, "side": "back", "odds": 2.4, "stake": 5, "keep-in-play": False, + } + + def test_a_sell_lays_and_stakes_the_backers_stake(self): + item = build_offer(request(side=Side.SELL, price=Decimal("0.4"), quantity=Decimal("10"))) + assert item["side"] == "lay" and item["odds"] == 2.5 and item["stake"] == 4 + + def test_odds_snap_to_the_ladder_on_the_cheaper_side(self): + # 1/0.41 = 2.439: a buy pays no more (odds 2.44), a sell receives no less (odds 2.42). + assert odds_for(Side.BUY, Decimal("0.41")) == Decimal("2.44") + assert odds_for(Side.SELL, Decimal("0.41")) == Decimal("2.42") + assert odds_for(Side.BUY, Decimal("0.5")) == odds_for(Side.SELL, Decimal("0.5")) == Decimal("2") + + def test_prices_off_the_ladder_are_refused(self): + with pytest.raises(InvalidOrder): + odds_for(Side.BUY, Decimal("0.0005")) + with pytest.raises(InvalidOrder): + odds_for(Side.SELL, Decimal("0.995")) + with pytest.raises(InvalidOrder): + odds_for(Side.BUY, Decimal("1")) + + def test_stakes_round_down_to_the_penny(self): + assert stake_for(Decimal("10"), Decimal("3")) == Decimal("3.33") + with pytest.raises(InvalidOrder, match="penny"): + stake_for(Decimal("0.01"), Decimal("3")) + + def test_keep_in_play_is_passed_through(self): + assert build_offer(request(params={"keep_in_play": True}))["keep-in-play"] is True + + @pytest.mark.parametrize("changes", [ + {"time_in_force": TimeInForce.FOK}, {"time_in_force": TimeInForce.GTD, "expires_at": 1}, + {"post_only": True}, {"reduce_only": True}, {"type": OrderType.ICEBERG}, + {"type": OrderType.MARKET, "price": None}, {"market_id": "matchbook:410343160570009-410343161840009"}, + ]) + def test_what_matchbook_does_not_hold_is_refused(self, changes): + from synpath.errors import BadRequest + + with pytest.raises(BadRequest): # InvalidOrder is a BadRequest + build_offer(request(**changes)) + + +class TestRecords: + def test_an_unmatched_offer(self): + order = order_of_offer(S["submit_unmatched"]["offers"][0]) + assert order.id == OFFER_ID and order.market_id == MAN_UTD and order.side == Side.BUY + assert order.status == OrderStatus.OPEN and order.quantity == Decimal("12.0") + assert order.price == Decimal("0.4166666667") and order.filled == 0 and order.remaining == Decimal("12.0") + assert order.created_at == 1487274572771 + + def test_a_matched_offer_carries_its_bets(self): + order = order_of_offer(S["submit_matched"]["offers"][0]) + assert order.status == OrderStatus.CLOSED and order.filled == order.quantity and order.remaining == 0 + assert order.average_price == Decimal("0.4166666667") and order.fee == 0 + + def test_a_delayed_offer_is_pending(self): + order = order_of_offer(S["submit_delayed"]["offers"][0]) + assert order.status == OrderStatus.PENDING and order.filled == 0 and order.info["delay"] == 6 + + def test_a_lay_is_a_sell_whose_stake_is_the_backers(self): + order = order_of_offer(S["offer_lay"]) + assert order.side == Side.SELL and order.status == OrderStatus.CLOSED + assert order.quantity == Decimal("2") * Decimal("1.51021") + # The documented liability is the stake times the odds less one. + assert Decimal(str(S["offer_lay"]["potential-liability"])) == pytest.approx(Decimal("2") * Decimal("0.51021"), abs=Decimal("0.00001")) + + def test_cancelled_and_flushed_offers_end(self): + assert order_of_offer(S["cancel"]).status == OrderStatus.CANCELED + flushed = S["offers"]["offers"][1] | {"status": "flushed"} + assert order_of_offer(flushed).status == OrderStatus.CANCELED + + def test_fills_are_the_matched_bets(self): + [fill] = fills_of_offer(S["submit_matched"]["offers"][0]) + assert fill.id == "413176315170213" and fill.order_id == "413176315170013" + assert fill.quantity == Decimal("12.0") and fill.price == Decimal("0.4166666667") and fill.side == Side.BUY + + def test_positions_net_backs_and_lays_per_runner(self): + [position] = positions_of_bets(S["current_bets"]) + assert position.market_id == "matchbook:919619528170041-919619528410042-919619528480041" + assert position.side == PositionSide.LONG and position.contracts == Decimal("5.00000") * Decimal("3.52000") + assert position.inventory_no == 0 and position.margin == Decimal("5.00000") + assert position.entry_price == (Decimal("5") / (Decimal("5") * Decimal("3.52"))).quantize(Decimal("1e-10")) + lay = json.loads(json.dumps(S["current_bets"])) + lay["markets"][0]["selections"].append({**lay["markets"][0]["selections"][0], "side": "lay"}) + [flat] = positions_of_bets(lay) + assert flat.side == PositionSide.FLAT and flat.contracts == 0 + + def test_settlements_read_the_bets_result_as_the_runners(self): + [settled] = settlements_of(S["settled_bets"]) + assert settled.market_id == "matchbook:881453329700042-881454180790042-881454181050042" + assert settled.held == PositionSide.LONG and settled.result == "no" and settled.won is False + assert settled.pnl == Decimal("-7.10498985") and settled.timestamp is not None + + +class TestAdapter: + def test_login_then_the_session_token_on_every_call(self): + venue = Venue(**{"POST /edge/rest/v2/offers": S["submit_unmatched"]}) + + async def go(): + async with adapter(venue) as mb: + return await mb.create_order(request(client_order_id="c-1")) + + order = run(go()) + login, submit = venue.requests + assert json.loads(login.content) == {"username": "jbloggs", "password": "verysecurepassword"} + assert submit.headers["session-token"] == S["login"]["session-token"] + assert json.loads(submit.content) == {"odds-type": "DECIMAL", "exchange-type": "back-lay", "offers": [ + {"runner-id": 401525949430009, "side": "back", "odds": 2.4, "stake": 5, "keep-in-play": False}]} + assert order.status == OrderStatus.OPEN and order.client_order_id == "c-1" + + def test_a_refused_session_logs_in_again_once(self): + answers = iter([httpx.Response(401, json={"errors": [{"messages": ["Please login to continue"]}]}), + httpx.Response(200, json=S["balance"])]) + venue = Venue(**{"GET /edge/rest/account/balance": lambda req: next(answers), "GET /edge/rest/account": S["account"]}) + + async def go(): + async with adapter(venue) as mb: + return await mb.fetch_balance() + + balance = run(go()) + assert len(venue.sent("POST", "/bpapi/rest/security/session")) == 2 + assert balance.currency == "GBP" # from the login's account + + def test_a_market_order_cancels_its_unmatched_rest(self): + venue = Venue(**{"POST /edge/rest/v2/offers": S["submit_unmatched"], + "DELETE /edge/rest/v2/offers": {"offers": [offer(S["submit_unmatched"]["offers"][0], + status="cancelled")]}}) + + async def go(): + async with adapter(venue) as mb: + return await mb.create_order(request(type=OrderType.MARKET)) + + order = run(go()) + [cancel] = venue.sent("DELETE", "/edge/rest/v2/offers") + assert cancel.url.params["offer-ids"] == OFFER_ID + assert order.status == OrderStatus.CANCELED and order.type == OrderType.MARKET + + def test_an_ioc_in_play_waits_out_the_delay_before_cancelling(self, monkeypatch): + slept: list[float] = [] + + async def sleep(seconds): + slept.append(seconds) + + monkeypatch.setattr("synpath.trading.matchbook.asyncio.sleep", sleep) + venue = Venue(**{"POST /edge/rest/v2/offers": S["submit_delayed"], + "DELETE /edge/rest/v2/offers": {"offers": []}, + f"GET /edge/rest/v2/offers/{OFFER_ID}": S["submit_matched"]["offers"][0]}) + + async def go(): + async with adapter(venue) as mb: + return await mb.create_order(request(time_in_force=TimeInForce.IOC)) + + order = run(go()) + assert slept == [6.5] and order.status == OrderStatus.CLOSED + + def test_a_matched_order_is_not_cancelled(self): + venue = Venue(**{"POST /edge/rest/v2/offers": S["submit_matched"]}) + + async def go(): + async with adapter(venue) as mb: + return await mb.create_order(request(type=OrderType.MARKET)) + + assert run(go()).status == OrderStatus.CLOSED and venue.sent("DELETE", "/edge/rest/v2/offers") == [] + + def test_create_orders_returns_one_result_each(self): + def submit(req): + sent = json.loads(req.content)["offers"] + row = S["submit_unmatched"]["offers"][0] + return {"offers": [row, {"runner-id": sent[1]["runner-id"], "status": "failed", + "errors": [{"messages": ["Insufficient funds."]}]}]} + + venue = Venue(**{"POST /edge/rest/v2/offers": submit}) + + async def go(): + async with adapter(venue) as mb: + return await mb.create_orders([request(), request(), request(post_only=True)]) + + placed, failed, invalid = run(go()) + assert placed.status == OrderStatus.OPEN + assert isinstance(failed, OrderRejected) and "Insufficient funds" in str(failed) + assert isinstance(invalid, InvalidOrder) + assert len(json.loads(venue.sent("POST", "/edge/rest/v2/offers")[0].content)["offers"]) == 2 + + def test_a_refused_submit_is_typed(self): + refused = {"errors": [{"messages": ["Insufficient funds to place the offer."]}]} + venue = Venue(**{"POST /edge/rest/v2/offers": httpx.Response(400, json=refused)}) + + async def go(): + async with adapter(venue) as mb: + return await mb.create_order(request()) + + with pytest.raises(InsufficientFunds): + run(go()) + + def test_cancel_and_a_refused_cancel_read_back(self): + venue = Venue(**{ + f"DELETE /edge/rest/v2/offers/{OFFER_ID}": S["cancel"], + "DELETE /edge/rest/v2/offers/1": httpx.Response(400, json=S["cancel_refused"]), + "GET /edge/rest/v2/offers/1": offer(S["cancel"], id=1), + }) + + async def go(): + async with adapter(venue) as mb: + return await mb.cancel_order(OFFER_ID), await mb.cancel_order("1") + + cancelled, already = run(go()) + assert cancelled.status == OrderStatus.CANCELED and already.status == OrderStatus.CANCELED + + def test_cancel_all_on_a_runner_a_market_or_everywhere(self): + venue = Venue(**{"DELETE /edge/rest/v2/offers": S["cancel_many"]}) + + async def go(): + async with adapter(venue) as mb: + return (await mb.cancel_all_orders(market_id=MAN_UTD), + await mb.cancel_all_orders(market_id="matchbook:410343160570009-410343161840009"), + await mb.cancel_all_orders()) + + assert run(go()) == (1, 1, 1) + runner, market, everything = venue.sent("DELETE", "/edge/rest/v2/offers") + assert runner.url.params["runner-ids"] == "401525949430009" + assert market.url.params["market-ids"] == "410343161840009" and not everything.url.params + + def test_edit_sends_the_current_and_new_odds_and_stake(self): + current = order_of_offer(offer(S["submit_unmatched"]["offers"][0])) + venue = Venue(**{f"PUT /edge/rest/v2/offers/{OFFER_ID}": offer(S["edit"], id=int(OFFER_ID))}) + + async def go(): + async with adapter(venue) as mb: + return await mb.edit_order(EditRequest(order_id=OFFER_ID, price=Decimal("0.3333333333"), + quantity=Decimal("300")), current=current) + + order = run(go()) + [sent] = venue.sent("PUT", f"/edge/rest/v2/offers/{OFFER_ID}") + assert json.loads(sent.content) == {"current-odds": 2.4, "new-odds": 3, "current-stake": 5, "new-stake": 100} + assert order.status == OrderStatus.OPEN and order.price == Decimal("0.3333333333") + + def test_a_refused_edit_says_why(self): + current = order_of_offer(S["submit_unmatched"]["offers"][0]) + venue = Venue(**{f"PUT /edge/rest/v2/offers/{OFFER_ID}": httpx.Response(400, json=S["edit_refused"])}) + + async def go(): + async with adapter(venue) as mb: + return await mb.edit_order(EditRequest(order_id=OFFER_ID, price=Decimal("0.5")), current=current) + + with pytest.raises(InvalidOrder, match="Wrong current odds"): + run(go()) + + def test_open_orders_page_through(self): + pages = iter([{**S["offers"], "total": 3}, {"offers": [S["offers"]["offers"][1]], "total": 3}]) + venue = Venue(**{"GET /edge/rest/v2/offers": lambda req: next(pages)}) + + async def go(): + async with adapter(venue) as mb: + return await mb.fetch_open_orders(market_id=MAN_UTD) + + assert len(run(go())) == 3 + first, second = venue.sent("GET", "/edge/rest/v2/offers") + assert first.url.params["status"] == "open,edited" and first.url.params["runner-ids"] == "401525949430009" + assert second.url.params["offset"] == "2" + + def test_my_trades_are_the_matched_bets(self): + venue = Venue(**{"GET /edge/rest/v2/offers": S["offers"]}) + + async def go(): + async with adapter(venue) as mb: + return await mb.fetch_my_trades() + + fills = run(go()) + assert [f.id for f in fills] == ["413176315170213"] and fills.next_cursor is None + assert venue.sent("GET", "/edge/rest/v2/offers")[0].url.params["status"] == "matched" + + def test_fetch_orders_pages_by_offset(self): + venue = Venue(**{"GET /edge/rest/v2/offers": {**S["offers"], "total": 5}}) + + async def go(): + async with adapter(venue) as mb: + return await mb.fetch_orders(status="canceled", limit=2) + + page = run(go()) + assert page.next_cursor == "2" + assert venue.sent("GET", "/edge/rest/v2/offers")[0].url.params["status"] == "cancelled,flushed" + + def test_balance_positions_and_settlements(self): + venue = Venue(**{ + "GET /edge/rest/account/balance": {**S["balance"], "balance": 100.5, "exposure": -5, "free-funds": 95.5}, + "GET /edge/rest/reports/v2/bets/current": S["current_bets"], + "GET /edge/rest/reports/v2/bets/settled": S["settled_bets"], + }) + + async def go(): + async with adapter(venue) as mb: + return await mb.fetch_balance(), await mb.fetch_positions(), await mb.fetch_settlements() + + balance, positions, settlements = run(go()) + assert (balance.total, balance.available, balance.locked) == (Decimal("100.5"), Decimal("95.5"), Decimal("5")) + assert positions[0].contracts == Decimal("17.6") and len(settlements) == 1 + assert venue.sent("GET", "/edge/rest/reports/v2/bets/current")[0].url.params["odds-type"] == "DECIMAL" + + def test_the_fee_is_commission_on_what_the_bet_wins(self): + venue = Venue(**{"GET /edge/rest/account": S["account"]}) + + async def go(): + async with adapter(venue) as mb: + return (await mb.fetch_fee_estimate(MAN_UTD, Side.BUY, Decimal("0.4"), Decimal("100")), + await mb.fetch_fee_estimate(MAN_UTD, Side.SELL, Decimal("0.4"), Decimal("100"))) + + buy, sell = run(go()) + assert buy.taker_fee == Decimal("1.2") and sell.taker_fee == Decimal("0.8") and buy.maker_fee == buy.taker_fee + + def test_heartbeat_on_and_off(self): + venue = Venue(**{"POST /edge/rest/v1/heartbeat": S["heartbeat"], "DELETE /edge/rest/v1/heartbeat": S["heartbeat_off"]}) + + async def go(): + async with adapter(venue) as mb: + return await mb.heartbeat(20), await mb.heartbeat(0) + + on, off = run(go()) + assert on["actual-timeout"] == 20 and off["action-performed"] == "HEARTBEAT_TERMINATED" + assert json.loads(venue.sent("POST", "/edge/rest/v1/heartbeat")[0].content) == {"timeout": 20} + + def test_an_unknown_offer_is_not_found(self): + from synpath.trading.errors import OrderNotFound + + async def go(): + async with adapter(Venue()) as mb: + return await mb.fetch_order("999") + + with pytest.raises(OrderNotFound): + run(go()) + + +class TestWiring: + def test_credentials_are_needed(self): + with pytest.raises(CredentialsMissing): + MatchbookTrading(None) + + def test_the_client_and_the_engine_build_it(self): + import synpath + from synpath.engine.__main__ import ADAPTERS + + creds = MatchbookCredentials(username="u", password="p") + trading = synpath.Client({"matchbook": creds}).trading("matchbook") + try: + assert isinstance(trading, MatchbookTrading) + finally: + run(trading.close()) + assert ADAPTERS["matchbook"] == ("..trading.matchbook", "MatchbookTrading") + + def test_doctor_names_the_login(self, tmp_path, monkeypatch, capsys): + from synpath.trading.__main__ import doctor + + for name in ("MATCHBOOK_USERNAME", "MATCHBOOK_PASSWORD"): + monkeypatch.delenv(name, raising=False) + env = tmp_path / ".env" + env.write_text("MATCHBOOK_USERNAME=jbloggs\nMATCHBOOK_PASSWORD=secret-pw-123\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + assert "user jbloggs" in out and "secret-pw-123" not in out diff --git a/tests/test_matchbook_ws.py b/tests/test_matchbook_ws.py new file mode 100644 index 0000000..0363975 --- /dev/null +++ b/tests/test_matchbook_ws.py @@ -0,0 +1,211 @@ +"""The polled Matchbook market stream: what a poll reads, and the events the +difference between two polls becomes. Every request goes to an +`httpx.MockTransport` answering from the recorded samples.""" +from __future__ import annotations + +import asyncio +import copy +import json +from decimal import Decimal + +import httpx +import pytest + +from synpath.ws.base import BookEvent, MarketStatusEvent, QuoteEvent, StreamStatusEvent +from synpath.ws.matchbook import MatchbookMarketStream, state_of, yes_levels + +from test_matchbook import ARSENAL, ARSENAL_EVERTON, ARSENAL_ID, EVENTS, MATCH_ODDS, MATCH_ODDS_ID, RACE, WIN, Api + +pytestmark = pytest.mark.anyio + + +@pytest.fixture(autouse=True) +def no_ambient_credentials(monkeypatch, tmp_path): + for name in ("MATCHBOOK_USERNAME", "MATCHBOOK_PASSWORD"): + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + + +def stream(api: Api | None = None, **kwargs) -> tuple[MatchbookMarketStream, Api]: + api = api or Api() + client = httpx.AsyncClient(transport=httpx.MockTransport(api.handler)) + return MatchbookMarketStream(client=client, limiter=None, interval=0.01, **kwargs), api + + +def message(events: list[dict]) -> dict: + return {"op": "poll", "at": 1, "events": events, "polled": [str(e["id"]) for e in events]} + + +def with_prices(event: dict, market_id, runner_id, prices: list[dict], **runner_fields) -> dict: + """A copy of an event with one runner's prices (and fields) changed.""" + out = copy.deepcopy(event) + for market in out["markets"]: + if market["id"] == market_id: + for runner in market["runners"]: + if runner["id"] == runner_id: + runner["prices"] = prices + runner.update(runner_fields) + return out + + +def books(events): + return [e for e in events if isinstance(e, BookEvent)] + + +class TestRecords: + def test_levels_read_on_yes(self): + lay = yes_levels(ARSENAL["prices"], "lay") + assert lay[0] == (Decimal("0.6666666667"), Decimal("1388.11838") * Decimal("1.5")) + assert yes_levels([{"side": "back", "odds": 1, "available-amount": 5}, + {"side": "back", "odds": 2, "available-amount": 0}], "back") == [] + + @pytest.mark.parametrize("event,market,runner,expected", [ + ("open", "open", {"status": "open"}, ("open", None)), + ("open", "suspended", {"status": "open"}, ("paused", None)), + ("open", "closed", {"status": "closed"}, ("closed", None)), + ("open", "open", {"status": "paid", "withdrawn": True}, ("settled", "void")), + ("graded", "closed", {"status": "closed"}, ("settled", None)), + ]) + def test_state_of(self, event, market, runner, expected): + assert state_of(event, market, runner) == expected + + +class TestPolling: + async def test_nothing_watched_reads_nothing(self): + s, api = stream() + await s._open() + assert (await s.poll())["op"] == "idle" and api.requests == [] + + async def test_a_poll_reads_every_event_watched_in_one_request(self): + s, api = stream() + await s._open() + await s.watch_order_book([ARSENAL_ID, f"matchbook:{RACE['id']}-{WIN['id']}"]) + polled = await s.poll() + assert api.paths() == ["/edge/rest/events"] + params = api.requests[0].url.params + assert set(params["ids"].split(",")) == {str(ARSENAL_EVERTON["id"]), str(RACE["id"])} + assert params["include-prices"] == "true" and params["odds-type"] == "DECIMAL" + # Only the markets watched ride in the message. + arsenal = next(e for e in polled["events"] if e["id"] == ARSENAL_EVERTON["id"]) + assert [m["id"] for m in arsenal["markets"]] == [MATCH_ODDS["id"]] + assert api.requests[0].headers["User-Agent"].startswith("synpath/") + assert "session-token" not in api.requests[0].headers + + async def test_a_refusal_drops_the_connection(self): + s, _ = stream(Api({r"/edge/rest/events": httpx.Response(429, json={})})) + await s._open() + await s.watch_order_book([ARSENAL_ID]) + with pytest.raises(ConnectionError, match="429"): + await s.poll() + + +class TestBooks: + async def test_a_snapshot_then_only_what_changed(self): + s, _ = stream() + await s.watch_order_book([ARSENAL_ID]) + first = books(s.handle(message([ARSENAL_EVERTON]))) + assert [e.kind for e in first] == ["snapshot"] + assert first[0].best_bid == Decimal("0.6666666667") and first[0].best_ask == Decimal("0.6711409396") + assert first[0].info["currency"] == "GBP" + assert s.handle(message([ARSENAL_EVERTON])) == [] + moved = with_prices(ARSENAL_EVERTON, MATCH_ODDS["id"], ARSENAL["id"], [ + {"side": "back", "odds": 1.48, "decimal-odds": 1.48, "available-amount": 10, "currency": "GBP"}, + *[p for p in ARSENAL["prices"] if p["side"] == "lay"], + ]) + delta = books(s.handle(message([moved]))) + assert [e.kind for e in delta] == ["delta"] and delta[0].bids == () + assert s.book(ARSENAL_ID).best_ask == Decimal("0.6756756757") + assert s.book(ARSENAL_ID, "no").best_bid == Decimal("1") - Decimal("0.6756756757") + + async def test_a_whole_market_keeps_every_runner(self): + s, _ = stream() + await s._open() + await s.watch_order_book([MATCH_ODDS_ID]) + events = s.handle(await s.poll()) + assert {e.market_id for e in books(events)} == { + f"matchbook:{ARSENAL_EVERTON['id']}-{MATCH_ODDS['id']}-{r['id']}" for r in MATCH_ODDS["runners"]} + + async def test_a_ticker_fires_when_the_top_moves(self): + s, _ = stream() + await s.watch_ticker([ARSENAL_ID]) + first = s.handle(message([ARSENAL_EVERTON])) + assert len(first) == 1 and isinstance(first[0], QuoteEvent) and first[0].bid == Decimal("0.6666666667") + assert s.handle(message([ARSENAL_EVERTON])) == [] + empty = with_prices(ARSENAL_EVERTON, MATCH_ODDS["id"], ARSENAL["id"], []) + assert s.handle(message([empty]))[0].bid is None + + async def test_a_runner_no_longer_listed_has_an_empty_book(self): + s, _ = stream() + await s.watch_order_book([ARSENAL_ID]) + s.handle(message([ARSENAL_EVERTON])) + closed = {**ARSENAL_EVERTON, "status": "closed", "markets": []} + delta = books(s.handle(message([closed]))) + assert delta and s.book(ARSENAL_ID).best_bid is None + + async def test_after_a_drop_the_next_poll_is_a_snapshot_and_resynced(self): + s, _ = stream() + await s.watch_order_book([ARSENAL_ID]) + s.handle(message([ARSENAL_EVERTON])) + s.on_disconnect() + assert not s.book(ARSENAL_ID).ready + assert [e.kind for e in books(s.handle(message([ARSENAL_EVERTON])))] == ["snapshot"] + status = s._queue.get_nowait() + assert isinstance(status, StreamStatusEvent) and status.state == "resynced" + + async def test_unwatch(self): + s, _ = stream() + await s.watch_order_book([ARSENAL_ID]) + await s.unwatch([ARSENAL_ID]) + assert s.wanted == {} and s.book(ARSENAL_ID) is None + + +class TestStatus: + async def test_status_changes_are_passed_on_change_only(self): + s, _ = stream() + race_market = f"matchbook:{RACE['id']}-{WIN['id']}" + await s.watch_market_status([race_market]) + first = [e for e in s.handle(message([RACE])) if isinstance(e, MarketStatusEvent)] + assert {e.state for e in first} == {"open", "settled"} + withdrawn = next(e for e in first if e.state == "settled") + assert withdrawn.result == "void" and withdrawn.info["withdrawn"] is True + assert s.handle(message([RACE])) == [] + suspended = copy.deepcopy(RACE) + suspended["markets"][0]["status"] = "suspended" + paused = [e for e in s.handle(message([suspended])) if isinstance(e, MarketStatusEvent)] + assert {e.state for e in paused} == {"paused"} and len(paused) == len(WIN["runners"]) - 1 + + +class TestLoop: + async def test_the_stream_runs_through_the_base_loop(self): + s, _ = stream() + await s.watch_order_book([ARSENAL_ID]) + seen = [] + + async def read(): + async for event in s: + seen.append(event) + if any(isinstance(e, BookEvent) for e in seen): + return + + s.start() + await asyncio.wait_for(read(), timeout=5) + await s.close() + assert isinstance(seen[0], StreamStatusEvent) and seen[0].state == "connected" + assert any(isinstance(e, BookEvent) and e.market_id == ARSENAL_ID for e in seen) + + def test_capabilities_and_feeds(self): + from synpath.engine.feeds import default_streams + + assert MatchbookMarketStream.has["watch_order_book"] == "partial" + assert MatchbookMarketStream.has["watch_trades"] is False + streams = default_streams({"matchbook": object()}, {}) + assert isinstance(streams["matchbook"].market, MatchbookMarketStream) + assert streams["matchbook"].private is None and streams["matchbook"].market.session is None + + def test_the_interval_must_be_positive(self): + with pytest.raises(ValueError): + MatchbookMarketStream(interval=0) + + +def test_messages_are_json(): + json.dumps(message(EVENTS["events"])) From d1e7272d4331e040538a60b43be24d5232b06234 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 18:00:00 +0100 Subject: [PATCH 05/19] feat: add prophetx Co-Authored-By: Claude Opus 5.5 --- .env.example | 13 + README.md | 5 +- docs/api.md | 27 +- docs/capabilities.md | 114 ++- docs/streaming.md | 25 + docs/trading.md | 57 +- synpath/__init__.py | 10 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 11 + synpath/ids.py | 7 +- synpath/prophetx.py | 949 +++++++++++++++++++++++ synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 12 +- synpath/trading/credentials.py | 51 +- synpath/trading/init.py | 18 + synpath/trading/instruments.py | 7 + synpath/trading/prophetx.py | 773 ++++++++++++++++++ synpath/ws/__init__.py | 5 +- synpath/ws/prophetx.py | 631 +++++++++++++++ tests/samples/prophetx_auth.json | 8 + tests/samples/prophetx_balance.json | 10 + tests/samples/prophetx_markets_v3.json | 82 ++ tests/samples/prophetx_markets_v4.json | 34 + tests/samples/prophetx_sport_events.json | 34 + tests/samples/prophetx_tournaments.json | 45 ++ tests/samples/prophetx_ws.json | 124 +++ tests/test_init.py | 35 +- tests/test_prophetx.py | 375 +++++++++ tests/test_prophetx_trading.py | 410 ++++++++++ tests/test_prophetx_ws.py | 242 ++++++ 31 files changed, 4062 insertions(+), 64 deletions(-) create mode 100644 synpath/prophetx.py create mode 100644 synpath/trading/prophetx.py create mode 100644 synpath/ws/prophetx.py create mode 100644 tests/samples/prophetx_auth.json create mode 100644 tests/samples/prophetx_balance.json create mode 100644 tests/samples/prophetx_markets_v3.json create mode 100644 tests/samples/prophetx_markets_v4.json create mode 100644 tests/samples/prophetx_sport_events.json create mode 100644 tests/samples/prophetx_tournaments.json create mode 100644 tests/samples/prophetx_ws.json create mode 100644 tests/test_prophetx.py create mode 100644 tests/test_prophetx_trading.py create mode 100644 tests/test_prophetx_ws.py diff --git a/.env.example b/.env.example index 0934e99..e13e6d9 100644 --- a/.env.example +++ b/.env.example @@ -114,3 +114,16 @@ BETFAIR_JURISDICTION= # prohibited. SMARKETS_USERNAME= SMARKETS_PASSWORD= + +# ProphetX: every call, market data included, needs a key. The Trading API key +# pair (any KYC-approved account: prophetx.co -> Menu -> API Integration; the +# secret is shown once) reads market data, streams it and places orders; run +# each process on its own pair, since ProphetX counts sessions per key. +# PROPHETX_AFFILIATE_KEY (optional) is a Market Data API key that ProphetX's +# market data team issues on request; it reads market data only. +# PROPHETX_SANDBOX=1 uses ProphetX's sandbox, whose keys are made on +# sandbox.prophetx.dev. +PROPHETX_ACCESS_KEY= +PROPHETX_SECRET_KEY= +PROPHETX_AFFILIATE_KEY= +PROPHETX_SANDBOX= diff --git a/README.md b/README.md index 649c0d2..3ba9f5e 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,14 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | +| | [ProphetX](https://www.prophetx.co) | `prophetx` | ✓ sports (Trading API key pair, or a Market Data API key) | ✓ | WebSocket (Trading API key pair) | one book per line, both sides read it | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). +ProphetX, a US peer-to-peer sports exchange, is read the way SX Bet is: each line (a moneyline, or a spread or total at one number) is a yes/no market whose first side is YES, `prophetx:44285780-90de769f4f4977a04446ed203e957351` (the event, then the YES side's strike id), and the game is the event. Prices are American odds, converted exactly to probabilities. Every call needs a key: the Trading API key pair, which any KYC-approved ProphetX account generates under API Integration, or a Market Data API key issued by ProphetX on request. See [capabilities](docs/capabilities.md#prophetx). + ## Why Synpath - **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. @@ -135,7 +138,7 @@ fee.estimate(price=0.50, contracts=100) # 1.75 **Same code, every venue** ```python -for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets'] (Betfair and predict.fun need keys) +for venue_id in synpath.exchanges: # [..., 'betfair', 'smarkets', 'prophetx'] (Betfair, predict.fun and ProphetX need keys) with synpath.exchange(venue_id) as venue: page = venue.fetch_markets(limit=5) diff --git a/docs/api.md b/docs/api.md index dfbf90e..3568a09 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,11 +77,11 @@ and spot exchanges do not. | Method | Returns | Notes | |---|---|---| | `fetch_markets(query, limit, cursor, status, sort)` | `Page[Market]` | `limit` up to 100 | -| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read), order preserved, closed and settled markets included | +| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read; ProphetX 20 events a request), order preserved, closed and settled markets included | | `fetch_events(query, limit, cursor, status)` | `Page[Event]` | markets nested | | `fetch_market(market_id)` | `Market` | | | `fetch_order_book(market_id, side, depth)` | `OrderBook` | `side="yes"` (default) or `"no"` | -| `fetch_order_books(market_ids, side, depth)` | `dict[str, OrderBook]` | many books; one request on Polymarket (500 books), on Betfair (40 Betfair markets, every runner of each) and on Smarkets (200 Smarkets markets, every contract of each), one per market elsewhere | +| `fetch_order_books(market_ids, side, depth)` | `dict[str, OrderBook]` | many books; one request on Polymarket (500 books), on Betfair (40 Betfair markets, every runner of each) on Smarkets (200 Smarkets markets, every contract of each) and on ProphetX (20 events, every line of each), one per market elsewhere | | `fetch_trades(market_id, since, limit, cursor)` | `Page[Trade]` | newest page first, each page oldest first; `since` in ms; `next_cursor` null at the end | | `fetch_ohlcv(market_id, timeframe, since, until, limit)` | `list[Candle]` | in the YES price; check `price_source`. With `since`, the first `limit` bars from it; without, the newest `limit` | | `fetch_series(series_id)` | `Series` | Kalshi only | @@ -122,6 +122,22 @@ book.info["bids"], book.info["offers"] # Smarkets' own [price bps, quantity] smk.fetch_trades(contracts[0].id) # the short recent tape ``` +ProphetX needs a key for every call: the Trading API key pair +(`PROPHETX_ACCESS_KEY`, `PROPHETX_SECRET_KEY`) or a Market Data API key +(`PROPHETX_AFFILIATE_KEY`). It adds `fetch_event(id)` (one open game with its +lines), `fetch_tournaments()`, `fetch_sport_events(tournament_id, event_ids)`, +the raw `fetch_event_markets(event_ids)` and `lines(event_id)`, and, with the +key pair, `fetch_strikes(strike_ids)`; it takes `tournament_id` on +`fetch_markets` and `fetch_events`. It lists only games not started or live, +and serves no trades or candles over REST. See [ProphetX](capabilities.md#prophetx). + +```python +px = synpath.ProphetX() # PROPHETX_ACCESS_KEY / _SECRET_KEY, or PROPHETX_AFFILIATE_KEY +lines = px.fetch_markets(tournament_id=31, limit=9) # 31 is the NFL +book = px.fetch_order_book(lines[0].id) # side one's offers are YES asks, side two's YES bids +book.info["yes"], book.info["no"] # ProphetX's own levels, American prices +``` + Polymarket also has `fetch_order_books_by_token(token_ids, depth)`, by CLOB token id. @@ -204,7 +220,7 @@ result.matched # MarketLink | None Matching covers every venue this library speaks: `kalshi`, `polymarket`, `polymarket_us`, `opinion`, `hyperliquid`, `predict_fun`, `limitless` and -`sx_bet` (not Betfair or Smarkets yet). `venue=` asks one of them in particular; without it the answer is +`sx_bet` (not Betfair, Smarkets or ProphetX yet). `venue=` asks one of them in particular; without it the answer is the first venue listing the same question, in the order Polymarket, Kalshi, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Polymarket US. @@ -256,13 +272,14 @@ A venue that cannot filter by one of them raises `NotSupported`. Polymarket does for `settled`, since its catalog does not publish resolution status, and Betfair for everything but `open`, since a closed market leaves its catalogue (read it by id instead). Smarkets filters on its events' states: `closed` is an -ended or suspended event, `settled` a settled or cancelled one. +ended or suspended event, `settled` a settled or cancelled one. ProphetX lists +only games not started or live, so it raises for everything but `open`. Anything outside the four raises `BadRequest` rather than being handed to the venue to interpret for itself. ### Identifiers -One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). +One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. On ProphetX a market is a line, `prophetx:-` with the strike id of its YES side (`prophetx:44285780-90de769f4f4977a04446ed203e957351`), and its event the game, `prophetx:44285780`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). ### Paging diff --git a/docs/capabilities.md b/docs/capabilities.md index b521b2a..7376636 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -6,21 +6,21 @@ Read `has` before you call. A capability a venue lacks raises `NotSupported` rather than returning an empty list, so "cannot" is never mistaken for "there is nothing". -| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | -|---|---|---|---|---|---|---|---|---|---|---| -| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | -| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | -| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | -| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | -| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | -| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | -| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | -| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | -| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | -| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | -| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | -| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | -| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | `prophetx` | +|---|---|---|---|---|---|---|---|---|---|---|---| +| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | yes, each line (a moneyline, a spread or total at one number) a market, its first side as YES under the venue's names; games not started or live, tournament by tournament; filter by tournament | +| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | yes, one per game, one tournament a page | +| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | yes, while the game is listed (not started or live) | +| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | yes, ten best levels a side: side one's offers are YES asks, side two's YES bids; NO mirrored | +| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | yes, twenty events a request | +| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | no: fills are broadcast on the WebSocket only (`watch_trades`) | +| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | no: no price history | +| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | +| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | no: the API documentation publishes no fee rate; each order and fill carries its fee | +| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | partial: no venue search; event, tournament and competitor names matched here over the open games | +| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | yes, twenty events a request | +| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | no: the venue lists in its own order | +| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | no | Polymarket US is the CFTC-regulated exchange, not the on-chain CLOB the `polymarket` adapter reads; the two share a brand and nothing else. It has @@ -182,6 +182,60 @@ betting on your own account; odds scraping is prohibited. Which countries Smarkets refuses API use from is not published with its API documentation and is unverified here. +### ProphetX + +ProphetX (formerly Prophet Exchange) is a US peer-to-peer sports exchange, +read the way SX Bet is. A ProphetX *market* at one strike (a moneyline; a +spread or total at one number) has two sides, and each side is its own +buy-only instrument with its own `strike_id`. That line is a Synpath market: +its first side is YES and its second NO, under the venue's names +(`market.yes.label`, `market.no.label`), and its id is the game and the YES +side's strike, `prophetx:44285780-90de769f4f4977a04446ed203e957351`. The game +is its event, `prophetx:44285780`. Both strike ids are on the outcomes' +`venue_token_id` and in `info["yes_strike_id"]` and `info["no_strike_id"]`; +`info["strike"]`, `info["type"]` and `info["sub_type"]` say which line it is. +A market with other than two sides (a three-way soccer moneyline) is not +binary and is left out. A spread or total can push, returning the stake. + +Prices are American odds. A side priced `-A` risks A to win 100, so its +implied probability is A/(A+100); one priced `+A` risks 100 to win A, so +100/(A+100); `+100` is one half. The conversion is exact (a fraction, +`synpath.prophetx.implied_probability`) and read prices carry it to eight +places. Each side's levels are what a taker can buy, built from the orders +resting on the other side: side one's are YES **asks** at their probability, +side two's are NO asks, which are YES **bids** at one minus theirs. A level's +`value` is the USD a taker can stake at it, and a stake S at probability P +returns S/P, so a level holds `value / P` contracts paying 1 each. ProphetX +lists each side's ten best levels; its own levels stay in the book's +`info["yes"]` and `info["no"]`. + +ProphetX lists only games not started or live, so listings take `status="open"` +and nothing else, and a game that has ended leaves the catalog. A game +`suspended`, `delayed` or `interrupted` is still `open` here, with +`active=False`. Tournaments (leagues) are walked in ProphetX's order; +`tournament_id=` reads one. There is no search route: `query` matches event, +tournament and competitor names over the open games. There are no trades or +candles over REST: fills are broadcast on the WebSocket, and +`ProphetXMarketStream.watch_trades` reads them. ProphetX charges a fee per +order, reported on orders and fills; its API documentation publishes no rate, +only a maker rebate on monthly maker volume, so there is no fee schedule. + +**Every call needs a key.** Two kinds read market data. The Trading API key +pair (`PROPHETX_ACCESS_KEY`, `PROPHETX_SECRET_KEY`) is generated by any +KYC-approved account under API Integration on prophetx.co (the secret is +shown once); it is exchanged at `/auth/login` for a bearer token that lasts +ten minutes and is renewed with the refresh token, and it also trades and +streams. ProphetX counts live sessions per access key and a second login adds +one, so one process should share one `ProphetXSession` (the trading adapter +shares its own with its catalog and streams), and two processes should use two +key pairs. A Market Data API key (`PROPHETX_AFFILIATE_KEY`) is issued by +ProphetX's market data team on request (jordan.grzeczka@prophetexchange.com); +it reads market data only, from the `/affiliate` routes. With both set the +market data key reads market data. `PROPHETX_SANDBOX=1` uses ProphetX's +sandbox (api.sandbox.prophetx.dev, demo funds), whose keys are made on +sandbox.prophetx.dev. A self-service key starts on ProphetX's default rate +tier, one request a second per endpoint, which the adapter keeps to. + Matching spans venues, so no adapter claims it. `synpath.match_market(id)` and `synpath.match_event(id)` are answered by Synpath's hosted matching service at `api.synpath.dev`, with a Synpath API key (the one `synpath keys create` saved, `SYNPATH_API_KEY`, or `api_key=`). See @@ -209,20 +263,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | -|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | -| `rfq` | yes | no | no | no | no | no | no | no | no | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `prophetx` | +|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; reads lag placing by seconds | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status and creation time, sixty days a read | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the account's fills, or one order's | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | no: no published rate | +| `rfq` | yes | no | no | no | no | no | no | no | no | no | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -258,6 +312,8 @@ Every stream answers the `watch_*` keys the same way | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | +| `ProphetXMarketStream` | yes, ten best levels a side, seeded from REST | partial: top of book | yes, the tournament's tape of fills | no | no | no | no | no | +| `ProphetXUserStream` | no | no | no | no | yes | yes | no | no | The exchange API's other gRPC streams carry one thing each and say so: drop copy and trade capture carry fills, position and position-change carry diff --git a/docs/streaming.md b/docs/streaming.md index 7a577f6..e5f43c2 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -43,6 +43,8 @@ asyncio.run(main()) | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | +| `ProphetXMarketStream` | the host `GET /websocket/connection-config` names (ProphetX's Talaria, Pusher protocol 7) | the Trading API key pair (every channel is private) | each line's ten best levels a side on every change, top of book, the tournament's tape of fills | +| `ProphetXUserStream` | the same host | the Trading API key pair | orders on every change; a fill with each match | Each stream's `has` answers the `watch_*` capability keys (`watch_order_book`, `watch_ticker`, `watch_trades`, `watch_market_status`, `watch_orders`, @@ -69,6 +71,7 @@ variable `synpath serve` reads it from: | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | | Betfair | the application key and a login (every Betfair call needs one) | `BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD` | | Smarkets | nothing; an optional login undelays the quotes | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | +| ProphetX | the Trading API key pair (prophetx.co -> API Integration); the Market Data API key has no stream | `PROPHETX_ACCESS_KEY`, `PROPHETX_SECRET_KEY` | `synpath serve` builds a market stream for Opinion, predict.fun and SX Bet from the API key alone (`load_market_data`), so a parent on any venue can @@ -204,6 +207,28 @@ jittered exponential backoff between attempts. says so in `info["delayed"]`. A change shows at the next poll, not as it happens, so its `watch_*` keys are `partial`. No `watch_trades`: the recent tape is short and unpaged; read it with `Smarkets.fetch_trades`. +- *ProphetX* streams over Talaria, its own server for the Pusher wire + protocol (protocol 7); the stream speaks it directly, so no Pusher client is + needed. On every connect it reads `GET /websocket/connection-config` for the + host (`ws_host`, else the Pusher cluster), opens `/app/`, takes the + `socket_id` from `pusher:connection_established`, and registers the + complete set of subscriptions at `POST /v4/mm/websocket` with + `X-CFTC-Terminology: true`. The answer authorizes each channel with its own + signature; the stream signs in with its `authenticated` block and joins + the channels it wants once `pusher:signin_success` arrives. Every message's + data is JSON in a string whose `payload` is base64 JSON. Books come from the + event channel (`{"type": "event"}`) as `v2.market_selections`: each message + is one line's two sides, ten levels each, so it replaces the book, and a + quiet line publishes nothing, so each book is seeded from REST when its + channel is joined. Messages carry a sequence number and an older one is + dropped. Trades come from the tournament channel as `trade_execution` + (bound as `v2.tournament_`), anonymous fills with the strike, its price + and the stake; `watch_trades` reads each line's tournament first. The + account's `orders` arrive on its private channel, one message per fill, + with `health_check` every five seconds. The stream sends `pusher:ping` + every 30 s and answers the server's. Every channel is private, so both + streams need the Trading API key pair; the engine runs them on the trading + adapter's session, since ProphetX counts sessions per key. **A book is right or marked not ready.** After a gap or a disconnect the book's `ready` is false until a snapshot arrives; deltas are not applied to diff --git a/docs/trading.md b/docs/trading.md index 27a5edb..e4da4be 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -25,7 +25,7 @@ pip install synpath Order entry ships in the base package. The install carries the venues' signing stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, -Ed25519 and private-key JWT for Polymarket US. Nothing further is needed to place an order. +Ed25519 and private-key JWT for Polymarket US; ProphetX signs nothing (a key pair buys a bearer token). Nothing further is needed to place an order. Polymarket and Opinion orders are hashed and signed by the Rust core (`synpath._core`), in about 20 microseconds rather than the 3 milliseconds @@ -71,6 +71,7 @@ private key. `synpath init` tells them apart. | predict.fun | mainnet: the API key (`pred_sk_...`). Test network: nothing | the API key and a private key: a plain wallet's, or a Predict account's owner key with its deposit address | the key at developers.predict.fun; a web-app account's owner key under Account -> Settings -> Privy wallet | | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | +| ProphetX | the Trading API key pair, or a Market Data API key | the Trading API key pair: an access key and a secret key, exchanged for a ten-minute bearer token | prophetx.co -> Menu -> API Integration, on any KYC-approved account (sandbox.prophetx.dev for the sandbox); the secret is shown once. The Market Data API key is issued by ProphetX on request | For Opinion, predict.fun and SX Bet the API key alone is a complete market-data setup: `synpath init` asks for it first and takes an empty @@ -95,6 +96,7 @@ The variables each venue reads: | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | +| ProphetX | `PROPHETX_ACCESS_KEY` and `PROPHETX_SECRET_KEY` (the Trading API: market data, streams, orders), `PROPHETX_AFFILIATE_KEY` (optional; the Market Data API, market data only), `PROPHETX_SANDBOX=1` (optional; ProphetX's sandbox, with demo funds) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, optional `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` and `BETFAIR_JURISDICTION`) @@ -573,3 +575,56 @@ each market on the YES leg. The balance is the proxy wallet's USDC: what is free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. + +## ProphetX + +`ProphetXTrading`, built to ProphetX's Trading API reference (the v4 `/mm` +routes at docs.prophetx.co). + +The access key and secret key are exchanged at `POST /auth/login` for a +bearer token that lasts ten minutes; the adapter renews it with the refresh +token before it lapses and logs in again only when that fails. ProphetX counts +live sessions per access key and a second login adds one, so the adapter, its +catalog and its streams share one session; run each process on its own key +pair. Nothing is signed. + +Every ProphetX order is a buy: each side of a line is its own instrument +(`strike_id`), and there is no sell. `buy` buys the line's first side (YES) at +the price given; `sell` buys the second side at `1 - price`. The price goes as +American odds on ProphetX's ladder (`GET /v4/mm/get_price_ladder`), snapped on +the cheaper side, so an order never pays more than asked. ProphetX counts the +stake in USD, so `n` contracts stake `n * P` at the ladder price's +probability `P`, rounded down to the cent, at least $1. A fill's contracts are +its stake over its price's probability: its stake plus its profit. +`reduce_only` is refused. `client_order_id` is ProphetX's `external_id`, its +idempotency key: up to 100 letters, digits, `-` and `_`, generated when absent; +a repeat is refused as `DuplicateClientOrderId`. + +Limits rest until filled or cancelled (`gtc`); `post_only` is ProphetX's +`ALO`, which ProphetX rejects after the fact (`wiped`) when it would have +crossed. `market` and `ioc` go as its `fillOrKill` at the price given as the +worst accepted: whatever is there fills and the rest is cancelled about two +seconds after the last fill. There is no all-or-none (`fok`) and no expiry +(`gtd`). An order whose event goes live is wiped, and so are two orders of one +account that would cross each other. + +`submit_order` answers before matching runs, so the order returned is +`pending`; `ProphetXUserStream` carries what became of it, one message per +fill ([Streaming](streaming.md)). Reads (`fetch_order`, the history, the fills) +come from a replica that can lag by seconds, while `cancel_order` is final when +it answers: an order it cancelled is reported cancelled even before the read +catches up. A partly filled order whose rest was cancelled or killed is +rewritten by ProphetX as a smaller, fully matched one; here it is `canceled` +with its fill. The default rate tier allows one request a second per endpoint +and turns the batch routes off, so `create_orders` and `cancel_orders` send +one at a time; `cancel_all_orders` cancels in the background, account-wide or +for both sides of one line. + +ProphetX has no positions route: `fetch_positions` nets the matched stakes of +the last sixty days' live orders per line on the YES leg, each at its limit +price. The balance is the wallet's USD: what is free to stake, and what +resting and matched orders hold; an exposure credit line, where ProphetX +grants one, is in `info`. The fee is reported on each order and fill; +ProphetX's API documentation publishes no rate, so there is no fee estimate. + +Check ProphetX's terms for where it may be used before trading. diff --git a/synpath/__init__.py b/synpath/__init__.py index b43731a..53965d6 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -49,6 +49,7 @@ from .sx_bet import SxBet from .betfair import Betfair from .smarkets import Smarkets +from .prophetx import ProphetX from .matching import match_event, match_market from .history import fetch_order_book_at, fetch_order_book_range, fetch_trades_range from .bucket import Bucket, BucketMember, BucketOrderReport, BucketPosition @@ -95,6 +96,7 @@ "sx_bet": SxBet, "betfair": Betfair, "smarkets": Smarkets, + "prophetx": ProphetX, } """Every venue this library speaks, by id.""" @@ -115,7 +117,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, ProphetXTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -145,7 +147,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "ProphetXMarketStream", "ProphetXUserStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -167,7 +169,7 @@ def __getattr__(name: str): __all__ = [ - "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Exchange", "exchange", "exchanges", + "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "ProphetX", "Exchange", "exchange", "exchanges", "Capability", "HttpClient", "RateLimiter", "Market", "Event", "Outcome", "Quote", "MarketStats", "OrderBook", "OrderLevel", "Trade", "Candle", "FeeSchedule", "Series", "Page", @@ -184,7 +186,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "ProphetXTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index 20f063a..ebde3a9 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -383,7 +383,13 @@ def _sx_bet(creds: Credentials) -> TradingExchange: return SxBetTrading(creds) # type: ignore[arg-type] +def _prophetx(creds: Credentials) -> TradingExchange: + from .trading.prophetx import ProphetXTrading + return ProphetXTrading(creds) # type: ignore[arg-type] + + _TRADING = { "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, + "prophetx": _prophetx, } diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..9d6d75c 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -65,6 +65,7 @@ "predict_fun": ("..trading.predict_fun", "PredictFunTrading"), "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), + "prophetx": ("..trading.prophetx", "ProphetXTrading"), } diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..e38ef29 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -164,6 +164,17 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] # login, when configured, only undelays the quotes. from ..ws.smarkets import SmarketsMarketStream out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) + elif venue == "prophetx" and getattr(creds, "trades", False): + # Every ProphetX channel is private: both streams register on the + # trading adapter's session, which ProphetX counts per key. + from ..ws.prophetx import ProphetXMarketStream, ProphetXUserStream + session = getattr(adapter, "session", None) + catalog = getattr(adapter, "catalog", None) + resolver = getattr(adapter, "side", None) + out[venue] = VenueStreams( + market=ProphetXMarketStream(credentials=creds, session=session, catalog=catalog), + private=ProphetXUserStream(credentials=creds, session=session, catalog=catalog, resolver=resolver), + ) else: log.info("synpath.engine.feeds: no stream for %s; its orders reach the engine by polling", venue) return out diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..e99e0c2 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -4,8 +4,9 @@ the venue's own id, separated by a colon: `kalshi:KXFEDDECISION-26SEP-C25`, `polymarket:2252244`, `polymarket_us:tec-mlb-nlchamp-2026-09-27-atl`, `opinion:5342`, `hyperliquid:7173`, `betfair:1.234567890-47972`, -`smarkets:164973231-456060183`. Nothing is hashed or re-keyed, so the part -after the colon is what the venue's own website and API call the thing. +`smarkets:164973231-456060183`, `prophetx:44285780-90de769f4f4977a04446ed203e957351`. +Nothing is hashed or re-keyed, so the part after the colon is what the +venue's own website and API call the thing. The venue in the id is what lets one field route a call: `synpath.Client` reads the prefix and hands the call to that venue's adapter. Every venue @@ -16,7 +17,7 @@ """ from __future__ import annotations -VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") +VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets", "prophetx") """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.""" diff --git a/synpath/prophetx.py b/synpath/prophetx.py new file mode 100644 index 0000000..9445528 --- /dev/null +++ b/synpath/prophetx.py @@ -0,0 +1,949 @@ +"""ProphetX: a US peer-to-peer sports exchange (formerly Prophet Exchange), read +from its Trading API or its Market Data API. + +**Every call needs a key; two kinds read market data.** The Trading API's key +pair (`PROPHETX_ACCESS_KEY`, `PROPHETX_SECRET_KEY`) is self-service for any +KYC-approved account (prophetx.co, API Integration). It is exchanged at `POST +/auth/login` for a bearer token that lasts ten minutes and is renewed with +`POST /auth/refresh`; see `ProphetXSession`. Its routes are `/mm/...` and +`/v4/mm/...`. The Market Data API's affiliate key (`PROPHETX_AFFILIATE_KEY`) is +issued on request by ProphetX's market data team and sent as it is, without +`Bearer`, on `/affiliate/...` and `/v4/affiliate/...`. With both set the +affiliate key reads market data, so the session's slots are left for trading. +Both APIs answer at `https://cash.api.prophetx.co/partner` and, on the sandbox +(`PROPHETX_SANDBOX=1`), at `https://api.sandbox.prophetx.dev/partner`. + +Four things about this venue that shape the adapter: + +**A line is a binary market with two named sides.** A ProphetX market at one +strike (a moneyline; a spread or a total at one number) has two sides, and +each side is its own buy-only instrument, named by its `strike_id`. The first +side ProphetX lists is this library's YES and the second its NO; the names stay +on the outcomes as their labels. A Synpath market is +`prophetx:-`, `prophetx:44285780-90de769f4f4977a04446ed203e957351`, +and the sport event is its event, `prophetx:44285780`. A spread or total can +push (the stake is returned), as on SX Bet. + +**Prices are American odds.** A side priced `-A` risks `A` to win 100, so its +implied probability is `A / (A + 100)`; one priced `+A` risks 100 to win `A`, +so `100 / (A + 100)`; `+100` and `-100` are both one half. Values strictly +between -100 and +100 do not exist. `implied_probability` converts exactly, +as a fraction; read prices are that fraction as a float to eight places. + +**The book is what a taker can buy, on each side.** Each side's levels are +built from orders resting on the other side: a level on side one at price `p` +offers YES at `implied(p)` (a YES ask), and a level on side two at `q` offers +NO at `implied(q)`, which is selling YES at `1 - implied(q)` (a YES bid). A +level's `value` is the USD a taker can stake at it; a stake `s` at +probability `P` returns `s / P`, so the level holds `value / P` contracts +paying 1 each. ProphetX lists each side's ten best levels. + +**No public tape over REST, and no published fee rate.** Executions are +broadcast on the WebSocket only (`ProphetXMarketStream.watch_trades`). The fee +is charged per order and shown on each order and fill; the API documentation +publishes no rate, only a maker rebate on monthly volume. +""" +from __future__ import annotations + +import asyncio +import threading +import time +from datetime import datetime +from fractions import Fraction +from typing import Any, Iterator + +import httpx + +from . import ids +from .base import ( + MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort, check_status, page_limit, +) +from .errors import AuthenticationError, BadRequest, MarketNotFound, NetworkError, NotSupported +from .types import BookSide, Event, Market, MarketStats, OrderBook, OrderLevel, Outcome, Page, Quote + +VENUE = "prophetx" +API_URL = "https://cash.api.prophetx.co/partner" +SANDBOX_URL = "https://api.sandbox.prophetx.dev/partner" +CURRENCY = "USD" + +LIMITER = RateLimiter(1.0, burst=2) +"""The default tier allows one request a second on almost every endpoint, per +key; one a second, shared process-wide, stays inside it.""" + +EVENTS_PER_REQUEST = 20 +"""Events one `get_multiple_markets` request asks for. ProphetX documents no +cap; twenty keeps a request's answer a reasonable size.""" + +STRIKES_PER_REQUEST = 20 +"""Strike ids one `get_strikes` request takes, as the reference caps it.""" + +MAX_TOURNAMENTS = 200 +"""Most tournaments one listing walks through.""" + +TOKEN_RENEW_S = 8 * 60 +"""Renew the ten-minute access token at eight minutes, as ProphetX advises, +when its answer gives no expiry.""" + +OPEN_STATES = ("not_started", "live") +"""The event states `get_sport_events` lists: every other state is unlisted.""" + +PAUSED_STATES = ("suspended", "delayed", "interrupted") +"""Event states in which trading is held but the event is not over.""" + +ENDED_STATES = ("ended", "closed", "cancelled", "postponed", "abandoned") + + +# --------------------------------------------------------------------------- +# Prices +# --------------------------------------------------------------------------- + +def implied_probability(price: Any) -> Fraction | None: + """American odds as the implied probability a buyer pays, exactly. + + `-A` (a favourite) risks `A` to win 100: `A / (A + 100)`. `+A` (an + underdog) risks 100 to win `A`: `100 / (A + 100)`. Both `+100` and `-100` + are 1/2. `None` for a missing price or one strictly between -100 and +100, + which American odds do not have (ProphetX's placeholder levels carry + those).""" + if price is None or isinstance(price, bool): + return None + try: + american = Fraction(str(price)) + except (TypeError, ValueError, ZeroDivisionError): + return None + if american >= 100: + return Fraction(100) / (american + 100) + if american <= -100: + return -american / (-american + 100) + return None + + +def probability(price: Any) -> float | None: + """American odds as a price in [0, 1], to eight places.""" + exact = implied_probability(price) + return round(float(exact), 8) if exact is not None else None + + +def american_of(probability_: Fraction) -> Fraction: + """The inverse of `implied_probability`: the American odds whose implied + probability is `probability_` (0 < p < 1), exactly. Above one half it is + the favourite's `-100p / (1 - p)`, at or below it `100(1 - p) / p`, so + even money is `+100`, the one ProphetX's ladder lists.""" + if not 0 < probability_ < 1: + raise BadRequest(f"{VENUE}: a probability strictly between 0 and 1 has American odds, not {probability_}") + if probability_ > Fraction(1, 2): + return -100 * probability_ / (1 - probability_) + return 100 * (1 - probability_) / probability_ + + +# --------------------------------------------------------------------------- +# Ids +# --------------------------------------------------------------------------- + +def line_native_id(event_id: Any, yes_strike_id: Any) -> str: + """`44285780-90de769f…`: the sport event, then the YES side's strike id.""" + return f"{event_id}-{yes_strike_id}" + + +def parse_line_id(native: str) -> tuple[str, str]: + """(event id, YES strike id) from a market's native id.""" + event_id, sep, strike_id = native.partition("-") + if not sep or not event_id.isdigit() or not strike_id: + raise BadRequest( + f"{VENUE}: {native!r} is not a market id; expected '-', " + f"e.g. prophetx:44285780-90de769f4f4977a04446ed203e957351 (a bare event id names an event)" + ) + return event_id, strike_id + + +def event_of(native: str) -> str: + """The sport event id from a market id or a bare event id.""" + if "-" in native: + return parse_line_id(native)[0] + if not native.isdigit(): + raise BadRequest(f"{VENUE}: {native!r} is not a ProphetX event or market id") + return native + + +# --------------------------------------------------------------------------- +# Pure normalizers +# --------------------------------------------------------------------------- + +def iso_ms(value: Any) -> int | None: + """Milliseconds from ProphetX's timestamps: ISO 8601 strings (with up to + nine fraction digits), or epoch seconds, milliseconds or nanoseconds.""" + if value in (None, "", 0): + return None + if isinstance(value, (int, float)) and not isinstance(value, bool): + number = int(value) + if number > 10 ** 17: + return number // 1_000_000 + if number > 10 ** 14: + return number // 1000 + return number if number > 10 ** 11 else number * 1000 + text = str(value).replace("Z", "+00:00") + head, dot, rest = text.partition(".") + if dot: + digits = rest[:len(rest) - len(rest.lstrip("0123456789"))] + text = f"{head}.{digits[:6].ljust(6, '0')}{rest[len(digits):]}" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + from datetime import timezone + + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp() * 1000) + + +def groups_of(selections: Any) -> list[list[dict[str, Any]]]: + """A line's two sides, each a list of price levels. v3/v4 nest a list per + side; a flat list (older versions) is one level per side.""" + rows = [s for s in selections or [] if isinstance(s, (list, dict))] + if rows and all(isinstance(s, dict) for s in rows): + return [[s] for s in rows] + return [[level for level in s if isinstance(level, dict)] for s in rows if isinstance(s, list)] + + +def lines_of(market: dict[str, Any]) -> list[dict[str, Any]]: + """A market's lines, each with exactly two sides: its `market_strikes` + entries where it has them (each carrying the market's own fields), else + the market itself. A side with no liquidity still lists one placeholder + level naming its strike. Markets with other than two sides (a three-way + moneyline) are left out: they are not binary.""" + strikes = [s for s in market.get("market_strikes") or [] if isinstance(s, dict)] + base = {k: v for k, v in market.items() if k != "market_strikes"} + units = [{**base, **strike} for strike in strikes] or [base] + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for unit in units: + sides = groups_of(unit.get("selections")) + if len(sides) != 2 or not all(sides): + continue + yes_id, no_id = sides[0][0].get("strike_id"), sides[1][0].get("strike_id") + if not yes_id or not no_id or str(yes_id) in seen: + continue + seen.add(str(yes_id)) + out.append({**unit, "_sides": sides, "_yes": str(yes_id), "_no": str(no_id)}) + return out + + +def side_levels(levels: list[dict[str, Any]], strike_id: str) -> list[tuple[float, float, dict[str, Any]]]: + """(probability, contracts, raw) for one side's real levels on its strike: + a stake of `value` at probability `P` buys `value / P` contracts. `value` + is the USD a taker can stake; `quantity` (the makers' original stake) is + read only where a level has no `value`.""" + out = [] + for level in levels: + if str(level.get("strike_id") or strike_id) != strike_id: + continue + exact = implied_probability(level.get("price")) + stake = level.get("value") if level.get("value") is not None else level.get("quantity") + try: + stake_f = float(stake) + except (TypeError, ValueError): + continue + if exact is None or stake_f <= 0: + continue + out.append((round(float(exact), 8), round(stake_f / float(exact), 6), level)) + return out + + +def book_of(line: dict[str, Any]) -> tuple[list[OrderLevel], list[OrderLevel]]: + """A line's YES bids and asks: side one's levels are YES asks at their + implied probability, side two's YES bids at one minus theirs. Best first.""" + one, two = line["_sides"] + asks = [OrderLevel(price=p, size=size) for p, size, _ in side_levels(one, line["_yes"])] + bids = [OrderLevel(price=round(1 - p, 8), size=size) for p, size, _ in side_levels(two, line["_no"])] + return sorted(bids, key=lambda level: level.price, reverse=True), sorted(asks, key=lambda level: level.price) + + +def status_of(event: dict[str, Any], line: dict[str, Any] | None = None) -> tuple[str, bool]: + """(normalized status, accepting orders) from the event's and market's states.""" + state = str(event.get("status") or "").lower() + market_state = str((line or {}).get("status") or "").lower() + if state in ENDED_STATES: + return "closed", False + if state in PAUSED_STATES or market_state in ("suspended", "closed", "inactive", "settled"): + return "open", False + return "open", True + + +def _tags(event: dict[str, Any]) -> list[str]: + seen: dict[str, None] = {} + for word in (event.get("sport_name"), event.get("tournament_name")): + if word and str(word).strip(): + seen.setdefault(str(word).strip().lower(), None) + return list(seen) + + +def event_title(event: dict[str, Any]) -> str: + return str(event.get("display_name") or event.get("name") or event.get("event_id") or "") + + +def _label(selection: dict[str, Any], fallback: str) -> str: + return str(selection.get("name") or selection.get("display_name") or fallback) + + +def normalize_market(event: dict[str, Any] | None, line: dict[str, Any]) -> Market: + """One line: its first side as YES, its second as NO, each under the + venue's name for it, priced from the levels it lists.""" + event = event or {} + event_id = str(event.get("event_id") or line.get("event_id") or line.get("sport_event_id") or "") + one, two = line["_sides"] + yes_label, no_label = _label(one[0], "Yes"), _label(two[0], "No") + native = line_native_id(event_id, line["_yes"]) + bids, asks = book_of(line) + bid, ask = (bids[0] if bids else None), (asks[0] if asks else None) + mid = round((bid.price + ask.price) / 2, 8) if bid and ask else None + yes = Quote(bid=bid.price if bid else None, bid_size=bid.size if bid else None, + ask=ask.price if ask else None, ask_size=ask.size if ask else None, mid=mid) + no = Quote(bid=round(1 - ask.price, 8) if ask else None, bid_size=ask.size if ask else None, + ask=round(1 - bid.price, 8) if bid else None, ask_size=bid.size if bid else None, + mid=round(1 - mid, 8) if mid is not None else None) + status, accepting = status_of(event, line) + tags = _tags(event) + market_name = line.get("display_name") or line.get("name") + scheduled = iso_ms(event.get("scheduled")) + return Market( + id=ids.qualify(VENUE, native), + venue=VENUE, + venue_market_id=native, + event_id=ids.qualify(VENUE, event_id) if event_id else None, + title=" — ".join(str(p) for p in (event_title(event), market_name, yes_label) if p), + description=f"{market_name or 'Market'}: {yes_label} or {no_label}.", + slug=None, + yes=Outcome(label=yes_label, quote=yes, venue_token_id=line["_yes"]), + no=Outcome(label=no_label, quote=no, venue_token_id=line["_no"]), + status=status, # type: ignore[arg-type] + native_status=str(event.get("status") or "") or None, + active=accepting, + open_timestamp=None, + open_datetime=None, + close_timestamp=None, + close_datetime=None, + resolution_timestamp=None, + resolution_datetime=None, + # Prices sit on a ladder of American odds, so the step in YES price + # varies along it. + tick_size=None, + face_value=1.0, + book_model="shared_complement", + stats=MarketStats(), + url=None, + image_url=None, + category=(tags or [None])[0], + tags=tags, + series_id=str(event["tournament_id"]) if event.get("tournament_id") is not None else None, + outcome_label=yes_label, + neg_risk=False, + settlement_sources=[], + info={ + "event_id": event_id or None, + "market_id": line.get("id"), + "yes_strike_id": line["_yes"], + "no_strike_id": line["_no"], + "yes_outcome_id": one[0].get("outcome_id"), + "no_outcome_id": two[0].get("outcome_id"), + "strike": line.get("strike", line.get("line")), + "type": line.get("type"), + "sub_type": line.get("sub_type"), + "category_name": line.get("category_name"), + "player_id": line.get("player_id"), + "scheduled": scheduled, + "selections": [one, two], + "market": {k: v for k, v in line.items() if not k.startswith("_") and k != "selections"}, + "event": event or None, + "currency": CURRENCY, + }, + ) + + +def normalize_event(event: dict[str, Any], markets: list[dict[str, Any]]) -> Event: + """A sport event with every binary line of its markets.""" + lines = [line for market in markets for line in lines_of(market)] + normalized = [normalize_market(event, line) for line in lines] + status, _ = status_of(event) + event_id = str(event.get("event_id") or "") + tags = _tags(event) + scheduled = iso_ms(event.get("scheduled")) + return Event( + id=ids.qualify(VENUE, event_id), + venue=VENUE, + venue_event_id=event_id, + title=event_title(event) or event_id, + description=None, + slug=None, + markets=normalized, + status=status, # type: ignore[arg-type] + native_status=str(event.get("status") or "") or None, + category=(tags or [None])[0], + tags=tags, + series_id=str(event["tournament_id"]) if event.get("tournament_id") is not None else None, + mutually_exclusive=None, + close_timestamp=None, + close_datetime=None, + url=None, + image_url=None, + settlement_sources=[], + info={ + "scheduled": scheduled, + "sport": event.get("sport_name"), + "tournament": event.get("tournament_name"), + "tournament_id": event.get("tournament_id"), + "competitors": event.get("competitors") or [], + "live_disabled": event.get("live_disabled"), + "sub_type": event.get("sub_type"), + }, + ) + + +def normalize_order_book( + line: dict[str, Any], *, market_id: str, side: BookSide = "yes", depth: int | None = None, +) -> OrderBook: + """A line's book read on YES; `side="no"` mirrors it. Sizes in contracts.""" + bids, asks = book_of(line) + derived = side == "no" + if derived: + bids, asks = ( + [OrderLevel(price=round(1 - level.price, 8), size=level.size) for level in asks], + [OrderLevel(price=round(1 - level.price, 8), size=level.size) for level in bids], + ) + elif side != "yes": + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + if depth: + bids, asks = bids[:depth], asks[:depth] + one, two = line["_sides"] + return OrderBook( + market_id=market_id, side=side, venue=VENUE, bids=bids, asks=asks, + timestamp=None, datetime=None, book_model="shared_complement", derived=derived, + # ProphetX lists each side's ten best levels. + depth_scope="top_n", + info={"yes": one, "no": two, "yes_strike_id": line["_yes"], "no_strike_id": line["_no"], "currency": CURRENCY}, + ) + + +def unwrap(payload: Any) -> Any: + """The answer inside ProphetX's `{"data": ...}` envelope, where there is one.""" + if isinstance(payload, dict) and "data" in payload: + return payload["data"] + return payload + + +def markets_by_event(payload: Any) -> dict[str, list[dict[str, Any]]]: + """`get_multiple_markets` keyed by event id. ProphetX says a few answers + come back as a flat list instead, grouped here by each market's event.""" + data = unwrap(payload) + out: dict[str, list[dict[str, Any]]] = {} + if isinstance(data, dict): + for key, rows in data.items(): + if isinstance(rows, list): + out[str(key)] = [r for r in rows if isinstance(r, dict)] + elif isinstance(rows, dict) and isinstance(rows.get("markets"), list): + out[str(key)] = [r for r in rows["markets"] if isinstance(r, dict)] + elif isinstance(data, list): + for row in data: + if isinstance(row, dict): + key = row.get("event_id") or row.get("sport_event_id") + out.setdefault(str(key), []).append(row) + return out + + +def markets_of(payload: Any) -> list[dict[str, Any]]: + """`get_markets` for one event: `{"event_id", "markets": [...]}`, or a bare list.""" + data = unwrap(payload) + rows = data.get("markets") if isinstance(data, dict) else data + return [r for r in rows or [] if isinstance(r, dict)] + + +def batched(items: list[str], size: int) -> Iterator[list[str]]: + for start in range(0, len(items), size): + yield items[start:start + size] + + +# --------------------------------------------------------------------------- +# Session +# --------------------------------------------------------------------------- + +class ProphetXSession: + """A Trading API session from an access key and secret key, kept in memory. + + `POST /auth/login` trades the key pair for an access token (ten minutes) + and a refresh token (three days); the access token is renewed with `POST + /auth/refresh` before it expires, and a fresh login is made only when the + refresh token has lapsed or is refused. ProphetX counts live sessions per + access key and a login does not replace an earlier one, so one session + object should serve one process: the read adapter, the trading adapter and + their streams share it. Usable from threads (`token`) and from an event + loop (`token_async`).""" + + def __init__( + self, *, access_key: str, secret_key: str, base_url: str = API_URL, client: httpx.Client | None = None, + timeout: float = 30.0, clock: Any = time.time, + ): + self.access_key = access_key + self._secret = secret_key + self.base_url = base_url.rstrip("/") + self._client = client + self._timeout = timeout + self._clock = clock + self._access: str | None = None + self._refresh: str | None = None + self._access_until = 0.0 + self._refresh_until = 0.0 + self._lock = threading.Lock() + self._async_lock: asyncio.Lock | None = None + + def __repr__(self) -> str: + return f"ProphetXSession(base_url={self.base_url!r}, token={'set' if self._access else 'none'})" + + # -- state ---------------------------------------------------------------- + + def _fresh(self) -> bool: + return bool(self._access) and self._clock() < self._access_until + + def _step(self) -> tuple[str, dict[str, Any]]: + """The call to make now: a refresh while the refresh token lasts, else a login.""" + if self._refresh and self._clock() < self._refresh_until: + return "/auth/refresh", {"refresh_token": self._refresh} + return "/auth/login", {"access_key": self.access_key, "secret_key": self._secret} + + def _apply(self, path: str, status: int, body: Any) -> bool: + """Take the answer to a login or refresh. `False` when a refresh was + refused and a login should follow.""" + if status == 429: + from .errors import RateLimitExceeded + + raise RateLimitExceeded(f"{VENUE}: too many logins") + if status >= 400: + if path == "/auth/refresh": + self._refresh = None + return False + code = body.get("error") if isinstance(body, dict) else None + raise AuthenticationError(f"{VENUE}: login refused: {code or status}", status=status, + body={"error": code} if code else None) + data = unwrap(body) or {} + token = data.get("access_token") if isinstance(data, dict) else None + if not token: + raise AuthenticationError(f"{VENUE}: the login answered without an access token") + now = self._clock() + self._access = str(token) + # An expiry already past (a clock that disagrees with ProphetX's) is + # ignored rather than trusted into a login on every call. + expires = _epoch_s(data.get("access_expire_time")) + renew = now + TOKEN_RENEW_S + self._access_until = min(expires - 60, renew) if expires and expires - 60 > now else renew + if data.get("refresh_token"): + self._refresh = str(data["refresh_token"]) + until = _epoch_s(data.get("refresh_expire_time")) + self._refresh_until = until - 60 if until and until - 60 > now else now + 2 * 86400 + return True + + def invalidate(self) -> None: + """Forget the access token (ProphetX said 401); the refresh token is kept.""" + self._access = None + self._access_until = 0.0 + + # -- sync ----------------------------------------------------------------- + + def _http(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client(timeout=self._timeout) + return self._client + + def token(self) -> str: + with self._lock: + for _ in range(2): + if self._fresh(): + break + path, body = self._step() + try: + response = self._http().post(f"{self.base_url}{path}", json=body) + except httpx.HTTPError as exc: + raise NetworkError(f"{VENUE}: login unreachable: {type(exc).__name__}") from None + self._apply(path, response.status_code, _json(response)) + assert self._access + return self._access + + def headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.token()}"} + + # -- async ---------------------------------------------------------------- + + async def token_async(self, client: httpx.AsyncClient) -> str: + if self._async_lock is None: + self._async_lock = asyncio.Lock() + async with self._async_lock: + for _ in range(2): + if self._fresh(): + break + path, body = self._step() + try: + response = await client.post(f"{self.base_url}{path}", json=body) + except httpx.HTTPError as exc: + raise NetworkError(f"{VENUE}: login unreachable: {type(exc).__name__}") from None + self._apply(path, response.status_code, _json(response)) + assert self._access + return self._access + + def close(self) -> None: + if self._client is not None: + self._client.close() + + +def _epoch_s(value: Any) -> float | None: + """An expiry in epoch seconds, from seconds, milliseconds or nanoseconds.""" + ms_ = iso_ms(value) + return ms_ / 1000 if ms_ else None + + +def _json(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError: + return None + + +class ProphetXAuth(httpx.Auth): + """Puts the session's bearer token on every request and retries once with + a renewed one when ProphetX answers 401.""" + + def __init__(self, session: ProphetXSession): + self.session = session + + def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]: + request.headers.update(self.session.headers()) + response = yield request + if response.status_code == 401: + self.session.invalidate() + request.headers.update(self.session.headers()) + yield request + + +class NoKey(httpx.Auth): + """Refuses to send: every ProphetX route needs a key.""" + + def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]: + raise AuthenticationError( + f"{VENUE}: every ProphetX call needs a key; set PROPHETX_ACCESS_KEY and PROPHETX_SECRET_KEY " + f"(self-service at prophetx.co, API Integration) or PROPHETX_AFFILIATE_KEY (ProphetX's Market Data API)" + ) + yield request # pragma: no cover - unreachable + + +def credentials_from_env() -> Any: + import os + + from .trading.credentials import load_prophetx, read_dotenv + + merged: dict[str, str] = {} + if os.path.exists(".env"): + merged.update(read_dotenv(".env")) + merged.update(os.environ) + return load_prophetx(merged) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class ProphetX(Exchange): + """ProphetX, read-only. Needs a Trading API key pair or a Market Data key. + + ```python + import synpath + + px = synpath.ProphetX() # PROPHETX_* from the environment + markets = px.fetch_markets(limit=10) + book = px.fetch_order_book(markets[0].id) + ``` + """ + + id = VENUE + name = "ProphetX" + book_model = "shared_complement" + has: dict[str, Capability] = { + "fetch_markets": True, + "fetch_events": True, + "fetch_market": True, + "fetch_markets_by_ids": True, + # ProphetX lists tournaments and events in its own order and has no sort. + "sort": False, + "fetch_order_book": True, + # Twenty events a request, every line of each. + "fetch_order_books": True, + # Executions are broadcast on the WebSocket only. + "fetch_trades": False, + "fetch_ohlcv": False, + "fetch_series": False, + # The API documentation publishes no fee rate. + "fetch_fee_schedule": False, + # No search endpoint: event, tournament and competitor names are + # matched here, over the open events. + "search": "partial", + "match_market": False, + "match_event": False, + } + + def __init__( + self, *, credentials: Any = None, affiliate_key: str | None = None, session: ProphetXSession | None = None, + sandbox: bool | None = None, base_url: str | None = None, timeout: float = 30.0, + limiter: RateLimiter | None = LIMITER, client: Any = None, session_client: httpx.Client | None = None, + login: bool | None = None, + ): + """`credentials` (else `PROPHETX_*` from the environment, when `login` + is not False), `affiliate_key` or `session` say how to authenticate; + nothing is sent until the first call. With an affiliate key, market + data is read from the Market Data API; otherwise from the Trading API + with the session.""" + if credentials is None and login is not False and session is None and affiliate_key is None: + credentials = credentials_from_env() + if sandbox is None: + sandbox = bool(getattr(credentials, "sandbox", False)) + self.sandbox = sandbox + url = base_url or (SANDBOX_URL if sandbox else API_URL) + self.affiliate_key = affiliate_key or getattr(credentials, "affiliate_key", None) + if session is None and getattr(credentials, "access_key", None) and getattr(credentials, "secret_key", None): + session = ProphetXSession(access_key=credentials.access_key, secret_key=credentials.secret_key, + base_url=url, client=session_client, timeout=timeout) + self.session = session + if client is None: + client = httpx.Client(timeout=timeout, follow_redirects=True) + client.headers["Accept"] = "application/json" + if self.affiliate_key: + client.headers["Authorization"] = self.affiliate_key + elif self.session is not None: + client.auth = ProphetXAuth(self.session) + else: + client.auth = NoKey() + self.http = HttpClient(url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + + @property + def api(self) -> str: + """`affiliate` (the Market Data API) or `mm` (the Trading API).""" + return "affiliate" if self.affiliate_key else "mm" + + def _get(self, path: str, params: Any = None) -> Any: + """GET `path` on whichever API this adapter reads: `{api}` in the path + becomes `affiliate` or `mm`.""" + return self.http.get(path.format(api=self.api), params) + + # -- raw reads ---------------------------------------------------------- + + def fetch_tournaments(self, *, active: bool = True) -> list[dict[str, Any]]: + """ProphetX's tournaments (leagues), those with open events by default.""" + data = unwrap(self._get("/{api}/get_tournaments", {"has_active_events": "true" if active else None})) or {} + rows = data.get("tournaments") if isinstance(data, dict) else data + return [t for t in rows or [] if isinstance(t, dict) and t.get("id") is not None] + + def fetch_sport_events( + self, *, tournament_id: Any = None, event_ids: list[str] | None = None, + ) -> list[dict[str, Any]]: + """Open sport events (not started or live), of one tournament or by id.""" + params: list[tuple[str, Any]] = [] + if tournament_id is not None: + params.append(("tournament_id", str(tournament_id))) + params += [("event_ids", str(e)) for e in event_ids or []] + data = unwrap(self._get("/{api}/get_sport_events", params or None)) or {} + rows = data.get("sport_events") if isinstance(data, dict) else data + return [e for e in rows or [] if isinstance(e, dict) and e.get("event_id") is not None] + + def fetch_event_markets(self, event_ids: list[str]) -> dict[str, list[dict[str, Any]]]: + """Raw v4 markets by event id, twenty events a request.""" + out: dict[str, list[dict[str, Any]]] = {} + for chunk in batched(list(dict.fromkeys(str(e) for e in event_ids)), EVENTS_PER_REQUEST): + if len(chunk) == 1: + out[chunk[0]] = markets_of(self._get("/v4/{api}/get_markets", {"event_id": chunk[0]})) + continue + payload = self._get("/v4/{api}/get_multiple_markets", [("event_ids", e) for e in chunk]) + out.update(markets_by_event(payload)) + return out + + def fetch_strikes(self, strike_ids: list[str]) -> dict[str, dict[str, Any]]: + """`strike_id` -> its event, market and type, twenty a request. Trading + API only: the Market Data API has no such route.""" + if self.session is None: + raise NotSupported(f"{VENUE}: strike lookup is a Trading API route; it needs the access key pair") + # With an affiliate key the client's own header is that key; this + # route takes the session's bearer token instead. + headers = self.session.headers() if self.affiliate_key else None + out: dict[str, dict[str, Any]] = {} + for chunk in batched(list(dict.fromkeys(strike_ids)), STRIKES_PER_REQUEST): + rows = unwrap(self.http.request("GET", "/v4/mm/get_strikes", params={"strike_ids": ",".join(chunk)}, + headers=headers)) or [] + for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict) and row.get("strike_id"): + out[str(row["strike_id"])] = row + return out + + # -- catalog ------------------------------------------------------------ + + def _events_with_markets(self, events: list[dict[str, Any]]) -> list[Event]: + if not events: + return [] + markets = self.fetch_event_markets([str(e["event_id"]) for e in events]) + return [normalize_event(e, markets.get(str(e["event_id"]), [])) for e in events] + + def _walk_events( + self, *, query: str | None, tournament_id: Any, start: int, + ) -> Iterator[tuple[int, list[dict[str, Any]]]]: + """(tournament index, its open events) from tournament `start` on, in + ProphetX's order; filtered to names matching `query` when given.""" + if tournament_id is not None: + tournaments = [{"id": tournament_id}] + else: + tournaments = self.fetch_tournaments()[:MAX_TOURNAMENTS] + needle = (query or "").strip().lower() + for index in range(start, len(tournaments)): + events = self.fetch_sport_events(tournament_id=tournaments[index]["id"]) + if needle: + events = [e for e in events if needle in _haystack(e, tournaments[index])] + yield index, events + + def fetch_markets( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", sort: str | None = None, tournament_id: Any = None, + ) -> Page[Market]: + """One page of open lines, tournament by tournament in ProphetX's + order, each event's markets in its own order. `query` matches event, + tournament and competitor names; `tournament_id` reads one tournament.""" + _open_only(status) + check_sort(sort, venue=VENUE, supported=False) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + start, skip = _cursor(cursor) + out: list[Market] = [] + for index, events in self._walk_events(query=query, tournament_id=tournament_id, start=start): + markets = [m for event in self._events_with_markets(events) for m in event.markets] + batch = markets[skip if index == start else 0:] + room = wanted - len(out) + out += batch[:room] + if len(batch) > room: + taken = (skip if index == start else 0) + room + return Page(out, next_cursor=f"{index}:{taken}") + if len(out) >= wanted: + return Page(out, next_cursor=f"{index + 1}:0") + return Page(out, next_cursor=None) + + def fetch_events( + self, *, query: str | None = None, limit: int | None = None, cursor: str | None = None, + status: str = "open", tournament_id: Any = None, + ) -> Page[Event]: + """Open sport events with their lines, one tournament per page (the + cursor walks ProphetX's tournaments with open events).""" + _open_only(status) + wanted = page_limit(limit) + start, _ = _cursor(cursor) + for index, events in self._walk_events(query=query, tournament_id=tournament_id, start=start): + if not events: + continue + normalized = self._events_with_markets(events[:wanted] if wanted else events) + following = None if tournament_id is not None else f"{index + 1}:0" + return Page(normalized, next_cursor=following) + return Page([], next_cursor=None) + + def fetch_event(self, event_id: str) -> Event: + """One open sport event with its lines, by `prophetx:`.""" + native = event_of(self.native(event_id)) + events = self.fetch_sport_events(event_ids=[native]) + event = next((e for e in events if str(e.get("event_id")) == native), None) + if event is None: + raise MarketNotFound(f"{VENUE}: no open event {native}") + return self._events_with_markets([event])[0] + + def lines(self, event_id: str) -> list[dict[str, Any]]: + """An event's raw binary lines (`lines_of` each market), for the streams + and the trading adapter, which need both strike ids of a line.""" + native = event_of(self.native(event_id)) + markets = self.fetch_event_markets([native]).get(native, []) + return [{**line, "event_id": line.get("event_id") or native} for m in markets for line in lines_of(m)] + + def fetch_market(self, market_id: str) -> Market: + """One line by id.""" + found = self.fetch_markets_by_ids([market_id]) + if not found: + raise MarketNotFound(f"{VENUE}: no open line {self.native(market_id)}") + return found[0] + + def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: + """Lines in the order asked for, their events twenty a request. Lines + ProphetX no longer lists are left out.""" + wanted = list(dict.fromkeys(parse_line_id(self.native(m)) for m in market_ids)) + event_ids = list(dict.fromkeys(e for e, _ in wanted)) + events = {str(e["event_id"]): e for e in self.fetch_sport_events(event_ids=event_ids)} + markets = self.fetch_event_markets(event_ids) + by_id: dict[tuple[str, str], Market] = {} + for event_id in event_ids: + for market in markets.get(event_id, []): + for line in lines_of(market): + by_id[(event_id, line["_yes"])] = normalize_market(events.get(event_id) or {"event_id": event_id}, line) + return [by_id[key] for key in wanted if key in by_id] + + # -- market data -------------------------------------------------------- + + def fetch_order_book(self, market_id: str, *, side: BookSide = "yes", depth: int | None = None) -> OrderBook: + books = self.fetch_order_books([market_id], side=side, depth=depth) + if not books: + raise MarketNotFound(f"{VENUE}: no open line {self.native(market_id)}") + return next(iter(books.values())) + + def fetch_order_books( + self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None, + ) -> dict[str, OrderBook]: + """Books for many lines, twenty events a request. An event id gives + every line of the event. Lines ProphetX no longer lists are left out.""" + natives = [self.native(m) for m in dict.fromkeys(market_ids)] + markets = self.fetch_event_markets([event_of(n) for n in natives]) + out: dict[str, OrderBook] = {} + for native in natives: + event_id = event_of(native) + strike = parse_line_id(native)[1] if "-" in native else None + for market in markets.get(event_id, []): + for line in lines_of(market): + if strike is not None and line["_yes"] != strike: + continue + synpath_id = self.qualify(line_native_id(event_id, line["_yes"])) + out[synpath_id] = normalize_order_book(line, market_id=synpath_id, side=side, depth=depth) + return out + + def fetch_trades(self, market_id: str, **_: Any) -> list: + raise NotSupported(f"{VENUE}: executions are broadcast on the WebSocket only; use ProphetXMarketStream.watch_trades") + + def fetch_ohlcv(self, market_id: str, **_: Any) -> list: + raise NotSupported(f"{VENUE}: ProphetX serves no price history") + + def fetch_fee_schedule(self, market_id: str) -> Any: + raise NotSupported(f"{VENUE}: the API documentation publishes no fee rate; each order and fill carries its fee") + + def close(self) -> None: + self.http.close() + + +def _haystack(event: dict[str, Any], tournament: dict[str, Any]) -> str: + words = [event.get("name"), event.get("display_name"), event.get("tournament_name"), tournament.get("name")] + words += [c.get("name") for c in event.get("competitors") or [] if isinstance(c, dict)] + words += [c.get("display_name") for c in event.get("competitors") or [] if isinstance(c, dict)] + return " ".join(str(w) for w in words if w).lower() + + +def _open_only(status: str) -> None: + check_status(status) + if status != "open": + raise NotSupported(f"{VENUE}: ProphetX lists only events not started or live") + + +def _cursor(cursor: str | None) -> tuple[int, int]: + """`tournament:skip` — the index of the tournament the listing resumes in, + and how many of its lines were already returned.""" + if not cursor: + return 0, 0 + head, sep, tail = cursor.partition(":") + if not sep or not head.isdigit() or not tail.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + return int(head), int(tail) + + +__all__ = [ + "ProphetX", "ProphetXSession", "implied_probability", "probability", "american_of", "normalize_market", + "normalize_event", "normalize_order_book", "lines_of", +] diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index aad1025..fb4eb90 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -81,12 +81,15 @@ def __getattr__(name: str): if name == "SxBetTrading": from .sx_bet import SxBetTrading return SxBetTrading + if name == "ProphetXTrading": + from .prophetx import ProphetXTrading + return ProphetXTrading raise AttributeError(name) __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "ProphetXTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index ab2f240..9f268ea 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -45,6 +45,12 @@ def doctor(dotenv: str | None) -> int: "SMARKETS_PASSWORD for undelayed quotes, or run synpath init. Betting needs Smarkets' " "approved API access (£150 one-off), not supported yet")) continue + if creds is not None and venue == "prophetx" and not creds.trades: + # The affiliate key alone: ProphetX's Market Data API. + stage = "sandbox" if creds.sandbox else "production" + rows.append((venue, f"market data ({stage})", "affiliate key loaded; set PROPHETX_ACCESS_KEY / " + "PROPHETX_SECRET_KEY to trade and stream")) + continue if creds is None and venue in market_data: stage = "testnet" if market_data[venue].testnet else "mainnet" private = ENV_NAMES[venue][0] @@ -52,7 +58,7 @@ def doctor(dotenv: str | None) -> int: continue if creds is None: names = ENV_NAMES[venue] - hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair") else "" + hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "prophetx") else "" rows.append((venue, "not configured", f"set {names[0]}{hint}")) continue stage = getattr(creds, "env", "-") @@ -91,6 +97,10 @@ def doctor(dotenv: str | None) -> int: stage = creds.jurisdiction login = "certificate" if creds.cert_path else "interactive" identity = f"user {creds.username}, app key loaded, {login} login" + elif venue == "prophetx": + stage = "sandbox" if creds.sandbox else "production" + affiliate = "; affiliate key loaded: market data from the Market Data API" if creds.affiliate_key else "" + identity = f"Trading API key pair loaded: market data, streams, orders{affiliate}" elif venue == "smarkets": stage = "login" identity = f"user {creds.username}: undelayed quotes; betting needs approved API access, not supported yet" diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..29031ff 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -45,6 +45,10 @@ (certificate login) and BETFAIR_JURISDICTION Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market (market data) data needs no login; one gives undelayed quotes + ProphetX PROPHETX_ACCESS_KEY, PROPHETX_SECRET_KEY (the Trading API: + market data, streams and orders), and optionally + PROPHETX_AFFILIATE_KEY (the Market Data API, market data + alone) and PROPHETX_SANDBOX """ from __future__ import annotations @@ -357,10 +361,39 @@ def secrets(self) -> list[str]: return [self.password] +@dataclass(frozen=True, repr=False) +class ProphetXCredentials: + """ProphetX: the Trading API's key pair, and optionally the Market Data + API's affiliate key. Either reads market data; only the key pair trades + and streams.""" + + access_key: str | None = field(default=None, repr=False) + secret_key: str | None = field(default=None, repr=False) + """The Trading API key pair, generated by any KYC-approved account under + API Integration on prophetx.co (the secret is shown once). Exchanged at + `/auth/login` for a ten-minute bearer token.""" + affiliate_key: str | None = field(default=None, repr=False) + """A Market Data API key, issued on request by ProphetX's market data + team. Sent as it is in `Authorization`; reads market data only.""" + sandbox: bool = False + """ProphetX's sandbox (api.sandbox.prophetx.dev, demo funds) instead of + production. Sandbox keys are made on sandbox.prophetx.dev.""" + _public = ("sandbox",) + __repr__ = _redacted_repr + + @property + def trades(self) -> bool: + return bool(self.access_key and self.secret_key) + + @property + def secrets(self) -> list[str]: + return [v for v in (self.access_key, self.secret_key, self.affiliate_key) if v] + + Credentials = ( KalshiCredentials | PolymarketCredentials | PolymarketUSCredentials | PolymarketUSExchangeCredentials | OpinionCredentials | HyperliquidCredentials | PredictFunCredentials | LimitlessCredentials | SxBetCredentials - | BetfairCredentials | SmarketsCredentials + | BetfairCredentials | SmarketsCredentials | ProphetXCredentials ) ENV_NAMES: dict[str, tuple[str, ...]] = { @@ -389,6 +422,7 @@ def secrets(self) -> list[str]: "BETFAIR_JURISDICTION", ), "smarkets": ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"), + "prophetx": ("PROPHETX_ACCESS_KEY", "PROPHETX_SECRET_KEY", "PROPHETX_AFFILIATE_KEY", "PROPHETX_SANDBOX"), } """What each venue reads. The first entry is the one whose absence means "not configured"; the rest are optional or have defaults.""" @@ -685,6 +719,20 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: return SmarketsCredentials(username=username, password=password) +def load_prophetx(env: Mapping[str, str]) -> ProphetXCredentials | None: + access, secret = env.get("PROPHETX_ACCESS_KEY") or None, env.get("PROPHETX_SECRET_KEY") or None + affiliate = env.get("PROPHETX_AFFILIATE_KEY") or None + if not access and not secret and not affiliate: + return None + if bool(access) != bool(secret): + raise CredentialsMissing( + "ProphetX's Trading API key is a pair: set both PROPHETX_ACCESS_KEY and PROPHETX_SECRET_KEY " + "(prophetx.co, API Integration; the secret is shown once)" + ) + sandbox = str(env.get("PROPHETX_SANDBOX") or "").strip().lower() in ("1", "true", "yes") + return ProphetXCredentials(access_key=access, secret_key=secret, affiliate_key=affiliate, sandbox=sandbox) + + LOADERS = { "kalshi": load_kalshi, "polymarket": load_polymarket, @@ -697,6 +745,7 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: "sx_bet": load_sx_bet, "betfair": load_betfair, "smarkets": load_smarkets, + "prophetx": load_prophetx, } diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..ecab68b 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -278,6 +278,23 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} +def ask_prophetx(p: Prompter) -> dict[str, str]: + p.say("\nProphetX: the Trading API key pair reads market data, streams it and places orders. Any KYC-approved") + p.say("account makes one at prophetx.co -> Menu -> API Integration (sandbox.prophetx.dev for the sandbox);") + p.say("the secret key is shown once. A Market Data API key (optional) is issued by ProphetX's market data") + p.say("team on request and reads market data only. Every key is typed hidden.") + access = p.value("Access key (typed hidden)", secret=True, check=_required("The access key")) + secret = p.value("Secret key (typed hidden)", secret=True, check=_required("The secret key")) + affiliate = p.value("Market Data API key (typed hidden, empty for none)", secret=True, default="", + check=lambda raw: None) + values = {"PROPHETX_ACCESS_KEY": access, "PROPHETX_SECRET_KEY": secret} + if affiliate: + values["PROPHETX_AFFILIATE_KEY"] = affiliate + if p.value("Use the sandbox? (y/n)", check=_choice("y", "n"), default="n") == "y": + values["PROPHETX_SANDBOX"] = "1" + return values + + VENUES: list[tuple[str, tuple[str, ...], Callable[[Prompter], dict[str, str]]]] = [ ("Kalshi", ("KALSHI_KEY_ID",), ask_kalshi), ("Polymarket", ("POLYMARKET_PRIVATE_KEY",), ask_polymarket), @@ -289,6 +306,7 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), + ("ProphetX", ("PROPHETX_ACCESS_KEY", "PROPHETX_AFFILIATE_KEY"), ask_prophetx), ] """Each venue, the settings any of which means it is set up already (an API key alone is, for market data), and the questions.""" diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 509a8fd..7f4e56e 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -66,6 +66,13 @@ # £0.05. "smarkets": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.0001"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("0.05")}, + # Prices are American odds on ProphetX's ladder (-10000 to +10000), whose + # steps are 0.0017 to 0.0076 in YES price: the tick here is finer than + # any, and the adapter snaps an order onto the ladder on the cheaper side. + # The stake is what is counted, in USD to the cent, at least $1, so + # contracts take any size to a millionth. + "prophetx": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.000001"), + "quantity_step": Decimal("0.000001"), "whole": False, "min_notional": Decimal("1")}, } diff --git a/synpath/trading/prophetx.py b/synpath/trading/prophetx.py new file mode 100644 index 0000000..1d0923b --- /dev/null +++ b/synpath/trading/prophetx.py @@ -0,0 +1,773 @@ +"""ProphetX order entry, and the venue's order and fill records on the YES leg. + +Built to ProphetX's Trading API reference (docs.prophetx.co, the v4 `/mm` +routes). + +**One key pair, one session.** The access key and secret key (self-service +for any KYC-approved account, under API Integration) are exchanged at `POST +/auth/login` for a bearer token that lasts ten minutes; `ProphetXSession` +renews it with the refresh token. ProphetX counts live sessions per access +key, and a second login adds one rather than replacing the first, so the +adapter, its catalog and the streams share one session. + +**Every order is a buy of one side.** ProphetX has no sell: each side of a +line is its own instrument (`strike_id`). `buy` buys the line's first side +(this library's YES) at the price given; `sell` buys the second side at one +minus it. The price goes to ProphetX as American odds on its price ladder +(`GET /v4/mm/get_price_ladder`), snapped to the ladder on the cheaper side so +the limit is never worse than the one asked for. The quantity ProphetX takes +is the stake in USD, to the cent, at least $1: an order for `n` contracts +stakes `n * P`, where `P` is the implied probability of the ladder price, +rounded down to the cent. A fill's contracts are its stake over its price's +probability, which is its stake plus its profit. + +**Time in force.** A limit rests until filled or cancelled (`gtc`); `post_only` +is ProphetX's `ALO`, rejected after the fact (`wiped`) if it would cross. A +market order, or an `ioc` limit, is ProphetX's `fillOrKill`: it takes what is +there at the price given and the rest is cancelled about two seconds after its +last fill. ProphetX has no all-or-none (`fok`) and no expiry (`gtd`). + +**Answers are early, reads are late.** `submit_order` answers before matching +runs (status `inactive`, so `pending` here); `get_order` and the history read +a replica that can lag by seconds. The account stream is the fast signal, and +`cancel_order` is final the moment it answers. + +**Positions** are built from the account's orders, since ProphetX has no +positions route: each live order's matched stake, in contracts at its limit +price, netted per line on the YES leg. A fill at a better price than the limit +holds a little more than that. + +**Fees** are charged per order and reported on orders and fills (`fee`); +ProphetX publishes no rate in its API documentation, so there is no estimate. + +The default rate tier allows one request a second per endpoint and turns the +batch endpoints off, so many orders or cancels are sent one at a time. +""" +from __future__ import annotations + +import asyncio +import re +import time +import uuid +from decimal import ROUND_DOWN, Decimal +from fractions import Fraction +from typing import Any + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound +from ..prophetx import ( + API_URL, SANDBOX_URL, ProphetXSession, implied_probability, iso_ms, line_native_id, parse_line_id, + unwrap, +) +from ..types import Page +from .base import TradingExchange +from .credentials import ProphetXCredentials +from .errors import ( + CredentialsMissing, DuplicateClientOrderId, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, + OrderRejected, +) +from .limiter import BudgetLimiter, Priority +from .types import ( + Account, Balance, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Side, + TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "prophetx" +CENT = Decimal("0.01") +CONTRACT_STEP = Decimal("0.000001") +MIN_STAKE = Decimal("1") +"""ProphetX's smallest stake, in USD.""" + +EXTERNAL_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$") +"""What ProphetX accepts as an `external_id`: up to 100 letters, digits, `-` and `_`.""" + +HISTORY_WINDOW_S = 60 * 86400 +"""The widest creation window one order-history read takes.""" + +HISTORY_PAGE = 1000 +MAX_PAGES = 50 + +CANCEL_RETRIES = 4 +"""How many times a cancel is retried while ProphetX says the order is still +being placed or matched (`409`, `425`).""" + +RETRY_CODES = ("order_is_placing", "order_is_matching", "order_opened_retry") +CANCEL_DONE_CODES = ("order_already_filled", "order_already_cancelled") + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return Decimal("0") + return Decimal(str(value)) + + +def exact(probability: Fraction) -> Decimal: + return Decimal(probability.numerator) / Decimal(probability.denominator) + + +def side_probability(american: Any) -> Fraction: + value = implied_probability(american) + if value is None: + raise InvalidOrder(f"{VENUE}: {american!r} is not an American price") + return value + + +def contracts_of(stake: Any, american: Any) -> Decimal: + """A stake at an American price as contracts paying 1 each: the stake + over the price's implied probability, which is the stake plus its profit. + Rounded down to a millionth.""" + value = D(stake) + if value <= 0: + return Decimal("0") + return (value / exact(side_probability(american))).quantize(CONTRACT_STEP, rounding=ROUND_DOWN) + + +def yes_price(american: Any, is_yes: bool) -> Decimal: + """A side's American price as the YES price: its probability on side + one, one minus it on side two.""" + probability = exact(side_probability(american)) + return probability if is_yes else Decimal("1") - probability + + +def side_of(is_yes: bool) -> Side: + return Side.BUY if is_yes else Side.SELL + + +def ladder_price(probability: Decimal, ladder: list[int]) -> int: + """The ladder price a buyer pays at most `probability` for: the dearest + one whose implied probability is not above it.""" + limit = Fraction(str(probability)) + best: tuple[Fraction, int] | None = None + for american in ladder: + implied = implied_probability(american) + if implied is None or implied > limit: + continue + if best is None or implied > best[0]: + best = (implied, int(american)) + if best is None: + raise InvalidOrder(f"{VENUE}: no price on ProphetX's ladder at or below {probability}") + return best[1] + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def status_of(row: dict[str, Any]) -> OrderStatus: + """An order's state from ProphetX's `status` and `matching_status`. + + `inactive` is still being validated; `invalid` failed validation (most + often the balance). A cancel of an order with no fill ends it `canceled`; + a cancel or a `fillOrKill` sweep of a partly filled one keeps it `open` + and `fully_matched` with `quantity` shrunk to what filled, which is a + cancel here. `wiped` (an event going live, a self-match, an `ALO` that + would have crossed) and `void` end it too. `settled`, `closed` and + `manually_settled` come only after the event, on what filled.""" + state = str(row.get("status") or "").lower() + matching = str(row.get("matching_status") or "").lower() + if state == "inactive": + return OrderStatus.PENDING + if state == "invalid": + return OrderStatus.REJECTED + if state in ("canceled", "cancelled", "wiped", "void"): + return OrderStatus.CANCELED + if state == "open": + if matching != "fully_matched": + return OrderStatus.OPEN + original = row.get("original_quantity") + if original not in (None, "") and D(original) > D(row.get("quantity")): + return OrderStatus.CANCELED + return OrderStatus.CLOSED + if state in ("settled", "closed", "manually_settled"): + filled = D(row.get("filled_quantity") if row.get("filled_quantity") is not None else row.get("total_filled_quantity")) + return OrderStatus.CLOSED if filled > 0 else OrderStatus.CANCELED + return OrderStatus.PENDING + + +def order_of_row( + row: dict[str, Any], *, market_id: str, is_yes: bool, account: Account | None = None, stream: bool = False, +) -> Order: + """One order as ProphetX reports it (REST, or the account stream with + `stream=True`, whose `filled_quantity` is one fill and whose running total + is `total_filled_quantity`). Stakes become contracts at the order's limit.""" + american = row.get("price") + status = status_of(row) + original = D(row.get("original_quantity") if row.get("original_quantity") not in (None, "") else row.get("quantity")) + filled_stake = D(row.get("total_filled_quantity") if stream else row.get("filled_quantity")) + quantity = contracts_of(original, american) + filled = contracts_of(filled_stake, american) + if status == OrderStatus.CLOSED: + filled = max(filled, contracts_of(row.get("quantity"), american)) + remaining = contracts_of(row.get("open_quantity"), american) if status in (OrderStatus.OPEN, OrderStatus.PENDING) else Decimal("0") + if status == OrderStatus.PENDING and row.get("open_quantity") in (None, ""): + remaining = quantity - filled + fee = row.get("fee") + return Order( + id=str(row.get("order_id") or row.get("id") or ""), + client_order_id=row.get("external_id") or None, + venue=VENUE, + account=account, + market_id=market_id, + side=side_of(is_yes), + type=OrderType.LIMIT, + time_in_force=TimeInForce.GTC, + status=status, + price=yes_price(american, is_yes), + quantity=quantity, + filled=filled, + remaining=max(Decimal("0"), remaining), + last_fill_price=yes_price(row["fill_price"], is_yes) if stream and row.get("fill_price") else None, + last_fill_quantity=contracts_of(row.get("filled_quantity"), row["fill_price"]) + if stream and row.get("fill_price") and row.get("filled_quantity") else None, + fee=D(fee) if fee not in (None, "") else None, + fee_currency="USD" if fee not in (None, "") else None, + created_at=iso_ms(row.get("created_at")), + updated_at=iso_ms(row.get("updated_at")), + info={**row, "american_price": american, "stake": str(original)}, + ) + + +def fill_of_row( + row: dict[str, Any], *, market_id: str, is_yes: bool, order_id: str | None = None, account: Account | None = None, +) -> Fill: + """One fill: a matched-detail or trade row (`price` is the fill's), or an + account-stream order update for a fill (`fill_price`, `matching_id`).""" + american = row.get("fill_price") if row.get("fill_price") is not None else row.get("price") + aggressive = row.get("aggressive") + fee = row.get("fee") + return Fill( + id=str(row.get("matching_id") or row.get("id") or ""), + order_id=str(order_id or row.get("order_id") or row.get("id") or ""), + client_order_id=row.get("external_id") or None, + venue=VENUE, + account=account, + market_id=market_id, + side=side_of(is_yes), + price=yes_price(american, is_yes), + quantity=contracts_of(row.get("filled_quantity") if row.get("fill_price") is not None else row.get("quantity"), + american), + fee=D(fee) if fee not in (None, "") else None, + fee_currency="USD", + liquidity=Liquidity.TAKER if aggressive is True else Liquidity.MAKER if aggressive is False else Liquidity.UNKNOWN, + timestamp=iso_ms(row.get("matched_at")) or iso_ms(row.get("updated_at")) or int(time.time() * 1000), + info=row, + ) + + +def code_of(exc: ExchangeError) -> str: + """ProphetX's error code: `{"error": "order_already_filled", "message": ...}`.""" + body = exc.body + if isinstance(body, dict) and isinstance(body.get("error"), str): + return body["error"] + return "" + + +def error_of(exc: ExchangeError) -> ExchangeError: + """ProphetX's refusal of an order as a typed error, by its code.""" + code = code_of(exc) + message = (exc.body or {}).get("message") if isinstance(exc.body, dict) else None + text = f"{VENUE}: {code or exc}" + (f" ({message})" if message and message != code else "") + if code == "order_external_id_existed": + return DuplicateClientOrderId(text) + if "balance" in code or "insufficient" in code: + return InsufficientFunds(text, body=exc.body) + if code in ("invalid_price", "invalid_quantity", "order_invalid_profit", "invalid_strike_id", "strike_id_invalid", + "invalid_external_id", "invalid_aggregator_id", "order_limit_exceeded"): + return InvalidOrder(text) + if code in ("event_not_available", "market_is_only_for_prematch", "supplemental_market_pre_match_only", + "sport_event_is_not_booked", "err_order_placement_disabled"): + return MarketHalted(text, body=exc.body, status=exc.status) + return OrderRejected(text, reason=code or None, info=exc.body if isinstance(exc.body, dict) else {}, + body=exc.body, status=exc.status) + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.time_in_force in (TimeInForce.FOK, TimeInForce.GTD, TimeInForce.DAY): + raise InvalidOrder(f"{VENUE}: ProphetX has no {request.time_in_force.value} orders; gtc or ioc") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") + if not Decimal("0") < request.price < Decimal("1"): + raise InvalidOrder(f"{VENUE}: the YES price must be strictly between 0 and 1") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: every ProphetX order buys a side; there is no reduce-only order") + if request.post_only and strategy_of(request) == "fillOrKill": + raise InvalidOrder(f"{VENUE}: a post-only order cannot also take at once") + if request.client_order_id is not None and not EXTERNAL_ID.match(request.client_order_id): + raise InvalidOrder(f"{VENUE}: client_order_id must be 1-100 letters, digits, '-' or '_'") + + +def strategy_of(request: OrderRequest) -> str | None: + """ProphetX's `order_strategy`: `fillOrKill` for an order that takes at + once, `ALO` for post-only, none for a resting limit.""" + if request.type == OrderType.MARKET or request.time_in_force == TimeInForce.IOC: + return "fillOrKill" + if request.post_only: + return "ALO" + return None + + +def build_order(request: OrderRequest, *, strike_id: str, ladder: list[int]) -> dict[str, Any]: + """The `submit_order` body for one request, before anything is sent: + the side's strike, its price on the ladder, and the stake for the + contracts asked for at that price.""" + check_request(request) + is_yes = request.side == Side.BUY + paid = D(request.price) if is_yes else Decimal("1") - D(request.price) + american = ladder_price(paid, ladder) + stake = (D(request.quantity) * exact(side_probability(american))).quantize(CENT, rounding=ROUND_DOWN) + if stake < MIN_STAKE: + raise InvalidOrder(f"{VENUE}: {request.quantity} contracts at {american} stake ${stake}; the minimum is $1") + body: dict[str, Any] = { + "external_id": request.client_order_id or uuid.uuid4().hex, + "strike_id": strike_id, + "price": american, + "quantity": float(stake), + } + strategy = strategy_of(request) + if strategy: + body["order_strategy"] = strategy + return body + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class ProphetXTrading(TradingExchange): + """ProphetX order entry. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.prophetx import ProphetXTrading + + async with ProphetXTrading(require("prophetx", load_credentials())) as px: + print(await px.fetch_balance()) + ``` + """ + + id = VENUE + name = "ProphetX" + has: dict[str, Capability] = { + "create_order": True, + # One at a time: the batch route is off on the default rate tier. + "create_orders": True, + "cancel_order": True, + "cancel_orders": True, + "cancel_all_orders": True, + "edit_order": False, + "fetch_order": True, + "fetch_open_orders": True, + # Sixty days of history a read, newest windows first. + "fetch_orders": True, + "fetch_my_trades": True, + # Built from the account's orders; ProphetX has no positions route. + "fetch_positions": "partial", + "fetch_balance": True, + "fetch_settlements": False, + "fetch_queue_position": False, + "fetch_fee_estimate": False, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: ProphetXCredentials, + *, + account_name: str = "default", + url: str | None = None, + catalog: Any = None, + session: ProphetXSession | None = None, + limiter: BudgetLimiter | None = None, + timeout: float = 30.0, + client: Any = None, + ): + import httpx + + if not (credentials.access_key and credentials.secret_key): + raise CredentialsMissing( + f"{VENUE}: trading needs the Trading API key pair; set PROPHETX_ACCESS_KEY and PROPHETX_SECRET_KEY " + "(prophetx.co, API Integration). PROPHETX_AFFILIATE_KEY reads market data only" + ) + self.credentials = credentials + self.sandbox = bool(credentials.sandbox) + self.account = Account(venue=VENUE, name=account_name) + self.limiter = limiter or BudgetLimiter(read_per_second=1, write_per_second=1, max_wait_s=10.0) + self.url = (url or (SANDBOX_URL if self.sandbox else API_URL)).rstrip("/") + self._client = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + self.http = AsyncHttpClient(self.url, limiter=None, client=self._client, venue=VENUE) + self._own_session = session is None + self.session = session or ProphetXSession( + access_key=credentials.access_key, secret_key=credentials.secret_key, base_url=self.url, timeout=timeout, + ) + self._own_catalog = catalog is None + if catalog is None: + from ..prophetx import ProphetX + + catalog = ProphetX(session=self.session, base_url=self.url, timeout=timeout) + self.catalog = catalog + """The read adapter: an event's lines, to know which side a strike is.""" + self._ladder: list[int] | None = None + self._sides: dict[str, tuple[str, bool]] = {} + """strike_id -> (Synpath market id, whether it is the YES side).""" + self._events: dict[str, str] = {} + """strike_id -> its event id, from `get_strikes`.""" + self._external: dict[str, str] = {} + """order_id -> external_id, for the orders placed here; a cancel needs both.""" + + # -- plumbing ------------------------------------------------------------- + + async def _call( + self, method: str, path: str, *, params: Any = None, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, + ) -> Any: + """One call with the session's bearer token, paced; ProphetX's `data` + unwrapped. A 401 renews the token once.""" + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + query = [(k, v) for k, v in params if v is not None] if isinstance(params, list) else \ + {k: v for k, v in (params or {}).items() if v is not None} + for attempt in range(2): + token = await self.session.token_async(self._client) + try: + payload = await self.http.request(method, path, params=query or None, json=body, + headers={"Authorization": f"Bearer {token}"}) + except AuthenticationError as exc: + if exc.status == 401 and attempt == 0: + self.session.invalidate() + continue + raise + return unwrap(payload) + raise AuthenticationError(f"{VENUE}: the session token was refused") # pragma: no cover + + async def ladder(self, *, fresh: bool = False) -> list[int]: + """The American prices ProphetX accepts. Read once and kept.""" + if self._ladder is None or fresh: + rows = await self._call("GET", "/v4/mm/get_price_ladder") or [] + self._ladder = sorted(int(p) for p in rows if isinstance(p, (int, float)) and not isinstance(p, bool)) + return self._ladder + + async def _learn_event(self, event_id: str) -> None: + """Which side of which line every strike of an event is.""" + lines = await asyncio.to_thread(self.catalog.lines, event_id) + for line in lines: + market_id = ids.qualify(VENUE, line_native_id(event_id, line["_yes"])) + self._sides[line["_yes"]] = (market_id, True) + self._sides[line["_no"]] = (market_id, False) + + async def side(self, strike_id: str, *, event_id: Any = None) -> tuple[str, bool]: + """(Synpath market id, is the YES side) for a strike. A strike whose + event ProphetX no longer lists cannot be paired with its other side: + it is named as its own line's YES (`prophetx:-`).""" + strike_id = str(strike_id) + if strike_id not in self._sides: + event = str(event_id) if event_id not in (None, "") else await self._event_of(strike_id) + if event: + try: + await self._learn_event(event) + except MarketNotFound: + pass + if strike_id not in self._sides: + return ids.qualify(VENUE, line_native_id(event or "0", strike_id)), True + return self._sides[strike_id] + + async def _event_of(self, strike_id: str) -> str | None: + if strike_id not in self._events: + rows = await self._call("GET", "/v4/mm/get_strikes", params={"strike_ids": strike_id}) or [] + for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict) and row.get("strike_id") and row.get("event_id") is not None: + self._events[str(row["strike_id"])] = str(row["event_id"]) + return self._events.get(strike_id) + + async def strike_for(self, market_id: str, is_yes: bool) -> str: + """The strike an order on this line's side buys.""" + event_id, yes_strike = parse_line_id(ids.native(VENUE, market_id)) + if is_yes: + return yes_strike + if yes_strike not in self._sides: + await self._learn_event(event_id) + synpath_id = ids.qualify(VENUE, line_native_id(event_id, yes_strike)) + no = next((s for s, (m, yes) in self._sides.items() if m == synpath_id and not yes), None) + if no is None: + raise MarketNotFound(f"{VENUE}: no open line {ids.native(VENUE, market_id)}") + return no + + async def _order(self, row: dict[str, Any]) -> Order: + market_id, is_yes = await self.side(str(row.get("strike_id") or ""), event_id=row.get("sport_event_id")) + order = order_of_row(row, market_id=market_id, is_yes=is_yes, account=self.account) + if order.client_order_id: + self._external[order.id] = order.client_order_id + return order + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest) -> Order: + """Place one order. ProphetX answers before matching runs, so the + order comes back `pending`; its fills arrive on the account stream or + a later `fetch_order`.""" + check_request(request) + strike = await self.strike_for(request.market_id, request.side == Side.BUY) + body = build_order(request, strike_id=strike, ladder=await self.ladder()) + try: + data = await self._call("POST", "/v4/mm/submit_order", body=body, kind="write") or {} + except (BadRequest, AuthenticationError) as exc: + raise error_of(exc) from None + except ExchangeError as exc: + if exc.status is not None and exc.status < 500: + raise error_of(exc) from None + raise + row = data.get("order") if isinstance(data, dict) else None + if not isinstance(row, dict): + raise OrderRejected(f"{VENUE}: submit_order answered without an order: {data}", reason="no_order") + market_id = ids.qualify(VENUE, ids.native(VENUE, request.market_id)) + is_yes = request.side == Side.BUY + order = order_of_row({"external_id": body["external_id"], **row}, market_id=market_id, is_yes=is_yes, + account=request.account or self.account) + self._external[order.id] = body["external_id"] + self._sides.setdefault(strike, (market_id, is_yes)) + return order.model_copy(update={ + "type": request.type, "time_in_force": request.time_in_force, "post_only": request.post_only, + "book": request.book, "trader": request.trader, "tags": request.tags, + "created_at": order.created_at or int(time.time() * 1000), + }) + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many orders, one request each (the batch route is off on the + default tier), one result each in the order asked for.""" + out: list[Order | Exception] = [] + for request in requests: + try: + out.append(await self.create_order(request)) + except (ExchangeError, InvalidOrder) as exc: + out.append(exc) + return out + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one order and read it back. ProphetX's cancel is final when + it answers; the read may lag, so a cancelled order still read as open + is reported cancelled. A cancel of an order already filled or already + cancelled reads it back as it stands.""" + external = self._external.get(order_id) + current: Order | None = None + if external is None: + current = await self.fetch_order(order_id) + external = current.client_order_id + if not external: + raise OrderRejected(f"{VENUE}: order {order_id} has no external_id to cancel it by", reason="no_external_id") + cancelled = False + for attempt in range(CANCEL_RETRIES): + try: + await self._call("POST", "/v4/mm/cancel_order", body={"external_id": external, "order_id": order_id}, + kind="write", priority=Priority.HIGH) + cancelled = True + break + except MarketNotFound as exc: + raise OrderNotFound(f"{VENUE}: no order {order_id}", body=exc.body, status=exc.status) from None + except ExchangeError as exc: + code = code_of(exc) + if code in CANCEL_DONE_CODES: + break + if code in RETRY_CODES and attempt < CANCEL_RETRIES - 1: + await asyncio.sleep(0.3 * (attempt + 1)) + continue + raise error_of(exc) from None + order = await self.fetch_order(order_id) + if cancelled and order.status in (OrderStatus.OPEN, OrderStatus.PENDING): + order = order.model_copy(update={ + "status": OrderStatus.CANCELED if order.filled < order.quantity else OrderStatus.CLOSED, + "remaining": Decimal("0"), + }) + return order + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel orders one at a time (the batch route is off on the + default tier), one result each.""" + out: list[Order | Exception] = [] + for order_id in order_ids: + try: + out.append(await self.cancel_order(order_id)) + except ExchangeError as exc: + out.append(exc) + return out + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int | None: + """Every resting order on a line (both its sides), or on the account. + ProphetX cancels in the background and gives no count: `None`, or 0 + when it says there was nothing to cancel.""" + if market_id is None: + try: + await self._call("POST", "/v4/mm/cancel_all_orders", kind="write", priority=Priority.HIGH) + except MarketNotFound: + return 0 + return None + event_id, yes_strike = parse_line_id(ids.native(VENUE, market_id)) + no_strike = await self.strike_for(market_id, False) + lines = await asyncio.to_thread(self.catalog.lines, event_id) + line = next((ln for ln in lines if ln["_yes"] == yes_strike), None) + if line is None or line.get("id") is None: + raise MarketNotFound(f"{VENUE}: no open line {ids.native(VENUE, market_id)}") + found = False + for strike in (yes_strike, no_strike): + try: + await self._call("POST", "/v4/mm/cancel_orders_by_market", body={ + "event_id": int(event_id), "market_id": int(line["id"]), "strike_id": strike, + }, kind="write", priority=Priority.HIGH) + found = True + except MarketNotFound: + continue + return None if found else 0 + + async def fetch_order(self, order_id: str) -> Order: + """One order by id, in any state. ProphetX's read lags its matching + by up to seconds, so an order placed a moment ago can be not found yet.""" + try: + row = await self._call("GET", f"/v4/mm/get_order/{order_id}") + except MarketNotFound as exc: + raise OrderNotFound(f"{VENUE}: no order {order_id} (yet: reads lag placing by seconds)", + body=exc.body, status=exc.status) from None + if not isinstance(row, dict) or not row: + raise OrderNotFound(f"{VENUE}: no order {order_id}") + return await self._order(row) + + async def _history( + self, *, status: str | None = None, since_s: int | None = None, cursor: str | None = None, + limit: int = HISTORY_PAGE, market: str | None = None, event: str | None = None, + ) -> tuple[list[dict[str, Any]], str | None]: + data = await self._call("GET", "/v4/mm/get_order_history", params={ + "from": since_s if since_s is not None else int(time.time()) - HISTORY_WINDOW_S + 60, + "status": status, "limit": limit, "next_cursor": cursor, "market_id": market, "event_id": event, + }) or {} + return [r for r in data.get("orders") or [] if isinstance(r, dict)], data.get("next_cursor") or None + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Every resting order of the last sixty days, or every one on a line.""" + event = parse_line_id(ids.native(VENUE, market_id))[0] if market_id else None + orders: list[Order] = [] + cursor = None + for _ in range(MAX_PAGES): + rows, cursor = await self._history(status="open", cursor=cursor, event=event) + for row in rows: + if str(row.get("matching_status") or "") == "fully_matched": + continue + orders.append(await self._order(row)) + if not cursor: + break + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + orders = [o for o in orders if o.market_id == wanted] + return orders + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of the account's orders, created since `since` (at most + sixty days back; the last sixty days by default). `status` is + ProphetX's own word (`open`, `canceled`, `settled`, ...).""" + event = parse_line_id(ids.native(VENUE, market_id))[0] if market_id else None + rows, following = await self._history( + status=status, since_s=int(since // 1000) if since else None, cursor=cursor, + limit=min(limit or 100, HISTORY_PAGE), event=event, + ) + orders = [await self._order(row) for row in rows] + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + orders = [o for o in orders if o.market_id == wanted] + return Page(orders, next_cursor=following) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """One page of this account's fills. With `order_id`, that order's + fills (`get_order_matched_detail`); otherwise every fill + (`get_trades`), filtered here to `market_id`.""" + params = {"from": int(since // 1000) if since else None, "limit": min(limit or 100, 100), "next_cursor": cursor} + if order_id: + order = await self.fetch_order(order_id) + data = await self._call("GET", "/v4/mm/get_order_matched_detail", params={**params, "order_id": order_id}) or {} + is_yes = order.side == Side.BUY + fills = [fill_of_row(row, market_id=order.market_id, is_yes=is_yes, order_id=order_id, account=self.account) + for row in data.get("matching_details") or [] if isinstance(row, dict)] + else: + data = await self._call("GET", "/v4/mm/get_trades", params=params) or {} + fills = [] + for row in data.get("trades") or []: + if not isinstance(row, dict): + continue + market, is_yes = await self.side(str(row.get("strike_id") or "")) + fills.append(fill_of_row(row, market_id=market, is_yes=is_yes, account=self.account)) + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + fills = [f for f in fills if f.market_id == wanted] + return Page(fills, next_cursor=data.get("next_cursor") or None) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """The wallet in USD: cash free to stake (`available`), and what + resting orders reserve plus what matched orders hold (`locked`). An + exposure credit line, where ProphetX grants one, is in `info`.""" + raw = await self._call("GET", "/v4/mm/get_balance") or {} + available = D(raw.get("balance")) + locked = D(raw.get("unmatched_order_balance")) + D(raw.get("matched_order_balance")) + return Balance( + venue=VENUE, account=account or self.account, currency="USD", + total=available + locked, available=available, locked=locked, buying_power=None, + timestamp=int(time.time() * 1000), info=raw, + ) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """Matched stakes of the last sixty days' live orders, netted per line + on the YES leg: contracts bought on side one less those on side two, + each order's at its limit price. Settled orders are left out.""" + event = ids.native(VENUE, event_id) if event_id else ( + parse_line_id(ids.native(VENUE, market_id))[0] if market_id else None) + net: dict[str, dict[str, Decimal]] = {} + cursor = None + for _ in range(MAX_PAGES): + rows, cursor = await self._history(status="open", cursor=cursor, event=event) + for row in rows: + if D(row.get("filled_quantity")) <= 0: + continue + order = await self._order(row) + entry = net.setdefault(order.market_id, {"yes": Decimal("0"), "no": Decimal("0"), "stake": Decimal("0")}) + entry["yes" if order.side == Side.BUY else "no"] += order.filled + entry["stake"] += D(row.get("filled_quantity")) + if not cursor: + break + out = [] + for market, entry in net.items(): + balance = entry["yes"] - entry["no"] + out.append(Position( + venue=VENUE, account=self.account, market_id=market, + side=PositionSide.LONG if balance > 0 else PositionSide.SHORT if balance < 0 else PositionSide.FLAT, + contracts=abs(balance), inventory_yes=entry["yes"], inventory_no=entry["no"], margin=entry["stake"], + timestamp=int(time.time() * 1000), info={"source": "order history"}, + )) + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + out = [p for p in out if p.market_id == wanted] + return out + + async def close(self) -> None: + await self.http.close() + if self._own_catalog: + self.catalog.close() + if self._own_session: + self.session.close() + + +__all__ = ["ProphetXTrading", "order_of_row", "fill_of_row", "build_order", "ladder_price", "status_of"] diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index 6260aee..dcee6d6 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -56,6 +56,9 @@ def __getattr__(name: str): if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream + if name in ("ProphetXMarketStream", "ProphetXUserStream"): + from . import prophetx + return getattr(prophetx, name) if name in ("HyperliquidMarketStream", "HyperliquidUserStream"): from . import hyperliquid return getattr(hyperliquid, name) @@ -75,5 +78,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "ProphetXMarketStream", "ProphetXUserStream", ] diff --git a/synpath/ws/prophetx.py b/synpath/ws/prophetx.py new file mode 100644 index 0000000..ae83afa --- /dev/null +++ b/synpath/ws/prophetx.py @@ -0,0 +1,631 @@ +"""ProphetX's WebSocket: books, the public tape, and the account's orders. + +ProphetX streams over Talaria, its own server for the Pusher wire protocol +(protocol 7), so this module speaks that protocol directly and needs no Pusher +client. The handshake, as ProphetX documents it: + +1. `GET /websocket/connection-config` (bearer token) names the host: `ws_host`, + else the Pusher cluster. Fetched again on every connect. +2. The socket opens at `wss:///app/?protocol=7` and announces + `pusher:connection_established` with a `socket_id`. +3. `POST /v4/mm/websocket` (bearer token, `X-CFTC-Terminology: true`) registers + the subscriptions wanted, always the complete set, and answers each channel + the account may join with its own `auth` signature and the event names to + bind. +4. `pusher:signin` with the answer's `authenticated` block, then one + `pusher:subscribe` per channel with that channel's `auth`. + +Every message's `data` is JSON in a string: `{"timestamp", "change_type", +"payload", "op"}`, where `payload` is base64-encoded JSON. Every channel is +private, so the stream needs the Trading API key pair (a `ProphetXSession`); +the Market Data API's affiliate key has no stream. + +**Books.** An event channel (`{"type": "event"}`) carries `v2.market_selections` +for every main market of the event: each message is one line's two sides, the +ten best levels a side, read on YES like the REST book (side one's levels YES +asks, side two's YES bids at one minus their probability, sizes `value / P` +contracts). A line that does not change publishes nothing, so each book is +seeded from REST once its channel is joined. + +**Trades** are on the tournament channel (`{"type": "tournament"}`), as +`trade_execution` under `v2.tournament_`: anonymous fills with the strike, +its price and the stake. + +**The account** is the private user channel: `orders` on every change to one +of its orders, one message per fill. ProphetX also sends `health_check` there +every five seconds. +""" +from __future__ import annotations + +import asyncio +import base64 +import json +from decimal import Decimal +from typing import Any, Awaitable, Callable + +from .. import ids +from ..base import Capability +from ..errors import AuthenticationError +from ..prophetx import ( + API_URL, SANDBOX_URL, VENUE, ProphetXSession, book_of, implied_probability, lines_of, line_native_id, + parse_line_id, unwrap, +) +from ..trading.types import Side +from .base import BookEvent, Event, LocalBook, QuoteEvent, Stream, TradeEvent + +PROTOCOL = 7 +PING = json.dumps({"event": "pusher:ping", "data": {}}) +"""The client's keepalive; Talaria answers `pusher:pong`.""" + +SIGNIN_TIMEOUT_S = 5.0 +"""How long the channels wait for `pusher:signin_success`.""" + + +def decode(data: Any) -> Any: + """A frame's `data`, which Pusher sends as JSON inside a string.""" + if isinstance(data, str): + try: + return json.loads(data) + except ValueError: + return data + return data + + +def payload_of(envelope: Any) -> Any: + """ProphetX's message: the base64 `payload` decoded to JSON.""" + if not isinstance(envelope, dict): + return None + raw = envelope.get("payload") + if isinstance(raw, (dict, list)): + return raw + if not raw: + return None + try: + return json.loads(base64.b64decode(raw)) + except (ValueError, TypeError): + return None + + +def ws_url(config: dict[str, Any]) -> str: + """The socket's URL from the connection config: its `ws_host` where + there is one, else the Pusher cluster's host.""" + key = config.get("key") + if not key: + raise AuthenticationError(f"{VENUE}: the connection config has no key: {sorted(config)}") + host = str(config.get("ws_host") or "").strip().rstrip("/") + if not host: + cluster = config.get("cluster") or "mt1" + host = f"ws-{cluster}.pusher.com" + base = host if host.startswith(("ws://", "wss://")) else f"wss://{host}" + return f"{base}/app/{key}?protocol={PROTOCOL}&client=synpath&version=1" + + +def levels(line: dict[str, Any]) -> tuple[list[tuple[Decimal, Decimal]], list[tuple[Decimal, Decimal]]]: + bids, asks = book_of(line) + return ([(Decimal(str(b.price)), Decimal(str(b.size))) for b in bids], + [(Decimal(str(a.price)), Decimal(str(a.size))) for a in asks]) + + +class _ProphetXStream(Stream): + """The Pusher protocol and ProphetX's registration both streams share.""" + + venue = VENUE + data_heartbeat = True + app_ping = PING + + def __init__( + self, *, session: ProphetXSession | None = None, credentials: Any = None, sandbox: bool | None = None, + api_url: str | None = None, config: Any = None, register: Any = None, ping_interval: float = 30.0, + **kwargs: Any, + ): + """`config` and `register` replace the two REST calls of the handshake + (a dict, or a callable returning one, sync or async); for tests.""" + if sandbox is None: + sandbox = bool(getattr(credentials, "sandbox", False)) + self.api_url = (api_url or (SANDBOX_URL if sandbox else API_URL)).rstrip("/") + if session is None and getattr(credentials, "access_key", None) and getattr(credentials, "secret_key", None): + session = ProphetXSession(access_key=credentials.access_key, secret_key=credentials.secret_key, + base_url=self.api_url) + if session is None and config is None: + raise AuthenticationError( + f"{VENUE}: every ProphetX channel is private; the stream needs the Trading API key pair " + "(PROPHETX_ACCESS_KEY, PROPHETX_SECRET_KEY)" + ) + super().__init__("", ping_interval=ping_interval, **kwargs) + self.session = session + self._config_source = config + self._register_source = register + self.socket_id: str | None = None + self.live: dict[str, dict[str, Any]] = {} + """Channel name -> its authorized entry, joined on this connection.""" + self._requested: set[str] = set() + self._signin: str | None = None + """`None`, `pending` (sent, unanswered) or `done`, on this connection.""" + self._held: list[dict[str, Any]] = [] + """Subscribe frames waiting for the sign-in to succeed.""" + + # -- handshake ------------------------------------------------------------ + + async def _source(self, source: Any, *args: Any) -> Any: + value = source(*args) if callable(source) else source + return await value if asyncio.iscoroutine(value) else value + + async def fetch_config(self) -> dict[str, Any]: + if self._config_source is not None: + return await self._source(self._config_source) or {} + return await asyncio.to_thread(self._rest, "GET", "/websocket/connection-config", None) + + def _rest(self, method: str, path: str, body: Any) -> Any: + import httpx + + assert self.session is not None + headers = {**self.session.headers(), "Content-Type": "application/json", "X-CFTC-Terminology": "true"} + response = httpx.request(method, self.api_url + path, json=body, headers=headers, timeout=15) + if response.status_code == 401: + self.session.invalidate() + headers.update(self.session.headers()) + response = httpx.request(method, self.api_url + path, json=body, headers=headers, timeout=15) + if response.status_code in (401, 403): + raise AuthenticationError(f"{VENUE}: {path} refused the session ({response.status_code})") + response.raise_for_status() + return unwrap(response.json()) if path != "/websocket/connection-config" else response.json() + + async def _open(self) -> Any: + self.url = ws_url(await self.fetch_config()) + return await super()._open() + + def subscriptions(self) -> list[dict[str, Any]]: + """The complete set of subscriptions to register.""" + return [] + + def wants(self, entry: dict[str, Any]) -> bool: + """Whether to join an authorized channel.""" + return False + + async def register(self) -> None: + """Register the complete set of subscriptions for this socket, sign + in, and join every authorized channel wanted and not yet joined.""" + if not self.socket_id: + return + body = {"socket_id": self.socket_id, "service": "pusher", "subscriptions": self.subscriptions()} + try: + if self._register_source is not None: + answer = await self._source(self._register_source, body) + else: + answer = await asyncio.to_thread(self._rest, "POST", "/v4/mm/websocket", body) + except Exception as exc: + self.status("error", f"could not register the subscriptions: {type(exc).__name__}: {exc}") + return + answer = unwrap(answer) if isinstance(answer, dict) and "authorized_channel" not in answer else answer + answer = answer or {} + for rejected in answer.get("rejected") or []: + self.status("error", f"subscription refused: {rejected}") + signin = answer.get("authenticated") + if self._signin is None and isinstance(signin, dict) and signin.get("auth"): + # Sign in first, and join the channels once it succeeds. + self._signin = "pending" + await self.send({"event": "pusher:signin", "data": {"auth": signin["auth"], + "user_data": signin.get("user_data") or ""}}) + self._later(self._signin_deadline()) + for entry in answer.get("authorized_channel") or []: + if not isinstance(entry, dict): + continue + channel = str(entry.get("channel_name") or "") + if not channel or channel in self._requested or not self.wants(entry): + continue + self._requested.add(channel) + self.live[channel] = entry + frame = {"event": "pusher:subscribe", "data": {"auth": entry.get("auth"), "channel": channel}} + if self._signin == "pending": + self._held.append(frame) + else: + await self.send(frame) + + async def _signed_in(self) -> None: + self._signin = "done" + held, self._held = self._held, [] + for frame in held: + await self.send(frame) + + async def _signin_deadline(self) -> None: + """Join the channels anyway when the sign-in is not answered in time: + each channel carries its own signature.""" + await asyncio.sleep(SIGNIN_TIMEOUT_S) + if self._signin == "pending": + self.status("error", f"no answer to pusher:signin in {SIGNIN_TIMEOUT_S:g}s; joining the channels anyway") + await self._signed_in() + + def on_disconnect(self) -> None: + self.socket_id = None + self.live.clear() + self._requested.clear() + self._signin = None + self._held = [] + + # -- messages ------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + name = str(message.get("event") or "") + channel = str(message.get("channel") or "") + data = decode(message.get("data")) + if name == "pusher:connection_established": + self.socket_id = str((data or {}).get("socket_id") or "") or None + self._later(self.register()) + return [] + if name == "pusher:ping": + self._later(self.send({"event": "pusher:pong", "data": {}})) + return [] + if name == "pusher:signin_success": + self._later(self._signed_in()) + return [] + if name == "pusher:pong": + return [] + if name == "pusher:error": + self.status("error", f"venue error: {data}") + if self._signin == "pending": + self._later(self._signed_in()) + return [] + if name == "pusher_internal:subscription_succeeded": + self.status("subscribed", channel, key=channel) + return self.on_subscribed(channel) + if name.startswith("pusher"): + return [] + return self.on_message(name, channel, data if isinstance(data, dict) else {}, payload_of(data)) + + def on_subscribed(self, channel: str) -> list[Event]: + return [] + + def on_message(self, name: str, channel: str, envelope: dict[str, Any], payload: Any) -> list[Event]: + return [] + + def _binds(self, channel: str) -> set[str]: + entry = self.live.get(channel) or {} + return {str(b.get("name")) for b in entry.get("binding_events") or [] if isinstance(b, dict)} + + def _later(self, coroutine: Any) -> asyncio.Task | None: + try: + return asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + coroutine.close() + return None + + +class ProphetXMarketStream(_ProphetXStream): + """Books and the public tape for ProphetX lines. Needs the Trading API + key pair: every ProphetX channel is private. + + ```python + async with ProphetXMarketStream(credentials=creds) as stream: + await stream.watch_order_book(["prophetx:44285780-90de769f4f4977a04446ed203e957351"]) + async for event in stream: + ... + ``` + """ + + name = "market" + has: dict[str, Capability] = { + "watch_order_book": True, + # Top of book from each book; ProphetX has no last price. + "watch_ticker": "partial", + "watch_trades": True, + "watch_market_status": False, + } + + def __init__(self, *, catalog: Any = None, **kwargs: Any): + super().__init__(**kwargs) + self.catalog = catalog + """A `ProphetX` for seeding books and finding a line's tournament; made + when first needed, on this stream's session.""" + self.books: dict[str, LocalBook] = {} + """Native line id -> its YES book.""" + self.tickers: set[str] = set() + self.trades: set[str] = set() + self.events: set[str] = set() + self.tournaments: set[str] = set() + self._sequences: dict[str, int] = {} + self._strikes: dict[str, tuple[str, bool]] = {} + """strike_id -> (native line id, is the YES side), for the tape.""" + + def _catalog(self) -> Any: + if self.catalog is None: + from ..prophetx import ProphetX + + self.catalog = ProphetX(session=self.session, base_url=self.api_url) + return self.catalog + + # -- intents -------------------------------------------------------------- + + async def watch_order_book(self, market_ids: list[str]) -> None: + """Each line's two sides on every change, seeded from REST.""" + for native in self._natives(market_ids): + self.books.setdefault(native, LocalBook()) + await self.register() + + async def watch_ticker(self, market_ids: list[str]) -> None: + """Top of book on every change.""" + self.tickers.update(self._natives(market_ids)) + await self.register() + + async def watch_trades(self, market_ids: list[str]) -> None: + """Fills on these lines, from their tournaments' tape.""" + natives = self._natives(market_ids) + self.trades.update(natives) + events = {parse_line_id(n)[0] for n in natives} + rows = await asyncio.to_thread(self._catalog().fetch_sport_events, event_ids=sorted(events)) + self.tournaments.update(str(r["tournament_id"]) for r in rows if r.get("tournament_id") is not None) + for event_id in events: + await self._learn(event_id) + await self.register() + + async def unwatch(self, market_ids: list[str]) -> None: + for native in self._natives(market_ids, track=False): + self.books.pop(native, None) + self.tickers.discard(native) + self.trades.discard(native) + self._sequences.pop(native, None) + + def _natives(self, market_ids: list[str], *, track: bool = True) -> list[str]: + natives = [] + for market_id in market_ids: + native = ids.native(VENUE, market_id) + event_id, _ = parse_line_id(native) + if track: + self.events.add(event_id) + natives.append(native) + return natives + + def book(self, market_id: str, side: str = "yes") -> LocalBook | None: + book = self.books.get(ids.native(VENUE, market_id)) + if book is None: + return None + return book if side != "no" else book.mirrored() + + # -- registration --------------------------------------------------------- + + def subscriptions(self) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + if self.events: + out.append({"type": "event", "ids": sorted(self.events)}) + if self.tournaments: + out.append({"type": "tournament", "ids": sorted(self.tournaments)}) + return out + + def wants(self, entry: dict[str, Any]) -> bool: + scope = entry.get("scope") if isinstance(entry.get("scope"), dict) else {} + if scope.get("event_id") is not None and not scope.get("sub_type"): + return str(scope["event_id"]) in self.events + binds = {str(b.get("name")) for b in entry.get("binding_events") or [] if isinstance(b, dict)} + return bool(self.trades) and any(b.startswith(("v2.tournament_", "tournament_")) for b in binds) + + # -- messages ------------------------------------------------------------- + + def on_disconnect(self) -> None: + super().on_disconnect() + self._sequences.clear() + for book in self.books.values(): + book.invalidate() + + def on_subscribed(self, channel: str) -> list[Event]: + scope = (self.live.get(channel) or {}).get("scope") or {} + if isinstance(scope, dict) and scope.get("event_id") is not None: + self._later(self._seed(str(scope["event_id"]))) + return [] + + async def _seed(self, event_id: str) -> None: + """The REST lines of an event, applied to books no message has filled yet.""" + try: + lines = await asyncio.to_thread(self._catalog().lines, event_id) + except Exception as exc: # pragma: no cover - the venue's API down + self.status("error", f"could not seed event {event_id}: {type(exc).__name__}: {exc}") + return + for line in lines: + self._remember(event_id, line) + native = line_native_id(event_id, line["_yes"]) + book = self.books.get(native) + if (book is not None and not book.ready) or (book is None and native in self.tickers): + for event in self._apply(native, line, sequence=None): + self.emit(event) + + async def _learn(self, event_id: str) -> None: + try: + lines = await asyncio.to_thread(self._catalog().lines, event_id) + except Exception as exc: # pragma: no cover - the venue's API down + self.status("error", f"could not read event {event_id}: {type(exc).__name__}: {exc}") + return + for line in lines: + self._remember(event_id, line) + + def _remember(self, event_id: str, line: dict[str, Any]) -> None: + native = line_native_id(event_id, line["_yes"]) + self._strikes[line["_yes"]] = (native, True) + self._strikes[line["_no"]] = (native, False) + + def on_message(self, name: str, channel: str, envelope: dict[str, Any], payload: Any) -> list[Event]: + binds = self._binds(channel) + if name.endswith("market_selections"): + # Bind the CFTC-vocabulary event; the legacy one carries the same + # updates in older field names. + if name != "v2.market_selections" and "v2.market_selections" in binds: + return [] + return self._on_selections(payload) + if envelope.get("change_type") == "trade_execution" and isinstance(payload, dict): + return self._on_trade(payload, envelope) + return [] + + def _on_selections(self, payload: Any) -> list[Event]: + if not isinstance(payload, dict) or not isinstance(payload.get("info"), dict): + return [] + info = payload["info"] + event_id = str(payload.get("sport_event_id") or info.get("sport_event_id") or "") + sequence = info.get("sequence_number") + events: list[Event] = [] + for line in lines_of({**info, "id": payload.get("market_id") or info.get("id")}): + self._remember(event_id, line) + native = line_native_id(event_id, line["_yes"]) + if native in self.books or native in self.tickers: + events += self._apply(native, line, sequence=int(sequence) if isinstance(sequence, (int, float)) else None) + return events + + def _apply(self, native: str, line: dict[str, Any], *, sequence: int | None) -> list[Event]: + if sequence is not None: + if sequence <= self._sequences.get(native, -1): + return [] + self._sequences[native] = sequence + bids, asks = levels(line) + market_id = ids.qualify(VENUE, native) + events: list[Event] = [] + book = self.books.get(native) + if book is not None: + recovering = not book.ready and book.timestamp is not None + book.replace(bids, asks) + from .base import now_ms + + book.timestamp = now_ms() + book.sequence = sequence + if recovering: + self.status("resynced", f"book {market_id}", key=market_id) + level_bids, level_asks = book.levels() + events.append(BookEvent( + venue=VENUE, market_id=market_id, side="yes", kind="snapshot", bids=level_bids, asks=level_asks, + best_bid=book.best_bid, best_ask=book.best_ask, sequence=sequence, + info={"yes_strike_id": line["_yes"], "no_strike_id": line["_no"]}, + )) + if native in self.tickers: + bid = max(bids, default=None, key=lambda level: level[0]) + ask = min(asks, default=None, key=lambda level: level[0]) + events.append(QuoteEvent( + venue=VENUE, market_id=market_id, bid=bid[0] if bid else None, bid_size=bid[1] if bid else None, + ask=ask[0] if ask else None, ask_size=ask[1] if ask else None, info={"sequence": sequence}, + )) + return events + + def _on_trade(self, payload: dict[str, Any], envelope: dict[str, Any]) -> list[Event]: + """One fill from the tape on a watched line, in the YES price. The + stake bought the strike's side at `fill_price`, so it is `stake / P` + contracts. `aggressive` says whether that side was the taker.""" + info = payload.get("info") if isinstance(payload.get("info"), dict) else payload + strike = str(info.get("strike_id") or "") + known = self._strikes.get(strike) + if known is None or known[0] not in self.trades: + return [] + native, is_yes = known + probability = implied_probability(info.get("fill_price", info.get("price"))) + try: + stake = Decimal(str(info.get("filled_quantity"))) + except (ArithmeticError, ValueError): + return [] + if probability is None or stake <= 0: + return [] + price = Decimal(probability.numerator) / Decimal(probability.denominator) + aggressive = info.get("aggressive") + taker = None + if isinstance(aggressive, bool): + buying_yes = is_yes if aggressive else not is_yes + taker = Side.BUY if buying_yes else Side.SELL + from ..prophetx import iso_ms + + return [TradeEvent( + venue=VENUE, market_id=ids.qualify(VENUE, native), + id=str(info.get("sequence_number")) if info.get("sequence_number") is not None else None, + price=price if is_yes else 1 - price, quantity=(stake / price).quantize(Decimal("0.000001")), + taker_side=taker, timestamp=iso_ms(envelope.get("timestamp")), info=info, + )] + + +Resolver = Callable[..., Awaitable[tuple[str, bool]]] + + +class ProphetXUserStream(_ProphetXStream): + """An account's orders and fills, from its private channel. Needs the + Trading API key pair. `resolver` (the trading adapter's `side`) says which + line and side an order's strike is; without one, the stream learns it from + the events' lines on its own session.""" + + name = "user" + private = True + has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True} + + def __init__(self, *, resolver: Resolver | None = None, catalog: Any = None, account_name: str = "default", + **kwargs: Any): + super().__init__(**kwargs) + from ..trading.types import Account + + self.resolver = resolver + self.catalog = catalog + self.account = Account(venue=VENUE, name=account_name) + self.orders = False + self.fills = False + self._sides: dict[str, tuple[str, bool]] = {} + self._chain: asyncio.Task | None = None + + async def watch_orders(self, market_ids: list[str] | None = None) -> None: + """Every order on the account; ProphetX has no per-line channel.""" + self.orders = True + await self.register() + + async def watch_my_trades(self, market_ids: list[str] | None = None) -> None: + """Every fill on the account, one message each.""" + self.fills = True + await self.register() + + def wants(self, entry: dict[str, Any]) -> bool: + binds = {str(b.get("name")) for b in entry.get("binding_events") or [] if isinstance(b, dict)} + return "orders" in binds and (self.orders or self.fills) + + def on_message(self, name: str, channel: str, envelope: dict[str, Any], payload: Any) -> list[Event]: + if name != "orders" or not isinstance(payload, dict) or not isinstance(payload.get("info"), dict): + return [] + row = payload["info"] + previous = self._chain + # Resolving a strike may need a REST read: queue it behind the last + # message so events keep the venue's order. + self._chain = self._later(self._emit_order(row, previous)) + return [] + + async def _emit_order(self, row: dict[str, Any], previous: asyncio.Task | None) -> None: + if previous is not None: + try: + await previous + except Exception: # pragma: no cover - reported by that task + pass + try: + for event in await self.events_of(row): + self.emit(event) + except Exception as exc: + self.status("error", f"unreadable order update: {type(exc).__name__}: {exc}") + + async def events_of(self, row: dict[str, Any]) -> list[Event]: + """The order, and its fill when the update is one.""" + from ..trading.prophetx import fill_of_row, order_of_row + from .base import FillEvent, OrderEvent + + market_id, is_yes = await self._side(str(row.get("strike_id") or ""), row.get("sport_event_id")) + events: list[Event] = [] + if self.orders: + order = order_of_row(row, market_id=market_id, is_yes=is_yes, account=self.account, stream=True) + events.append(OrderEvent(venue=VENUE, order=order, + native=f"{row.get('update_type') or ''}:{row.get('status') or ''}")) + if self.fills and row.get("update_type") == "matching" and row.get("fill_price") and row.get("filled_quantity"): + fill = fill_of_row(row, market_id=market_id, is_yes=is_yes, order_id=str(row.get("id") or ""), + account=self.account) + events.append(FillEvent(venue=VENUE, fill=fill)) + return events + + async def _side(self, strike_id: str, event_id: Any) -> tuple[str, bool]: + if self.resolver is not None: + return await self.resolver(strike_id, event_id=event_id) + if strike_id not in self._sides and event_id not in (None, ""): + if self.catalog is None: + from ..prophetx import ProphetX + + self.catalog = ProphetX(session=self.session, base_url=self.api_url) + for line in await asyncio.to_thread(self.catalog.lines, str(event_id)): + native = ids.qualify(VENUE, line_native_id(event_id, line["_yes"])) + self._sides[line["_yes"]] = (native, True) + self._sides[line["_no"]] = (native, False) + return self._sides.get(strike_id) or (ids.qualify(VENUE, line_native_id(event_id or "0", strike_id)), True) + + +__all__ = ["ProphetXMarketStream", "ProphetXUserStream", "ws_url", "decode", "payload_of"] diff --git a/tests/samples/prophetx_auth.json b/tests/samples/prophetx_auth.json new file mode 100644 index 0000000..4cc418e --- /dev/null +++ b/tests/samples/prophetx_auth.json @@ -0,0 +1,8 @@ +{ + "data": { + "access_token": "...", + "access_expire_time": 1234567890, + "refresh_token": "...", + "refresh_expire_time": 1234567890 + } +} diff --git a/tests/samples/prophetx_balance.json b/tests/samples/prophetx_balance.json new file mode 100644 index 0000000..574475a --- /dev/null +++ b/tests/samples/prophetx_balance.json @@ -0,0 +1,10 @@ +{ + "data": { + "balance": 1000.0, + "gec_balance": 500.0, + "matched_order_balance": 200.0, + "unmatched_order_balance": 50.0, + "unmatched_order_balance_status": "succeed", + "unmatched_order_last_synced_at": "2026-08-10T14:12:40.307908108Z" + } +} diff --git a/tests/samples/prophetx_markets_v3.json b/tests/samples/prophetx_markets_v3.json new file mode 100644 index 0000000..c3888ff --- /dev/null +++ b/tests/samples/prophetx_markets_v3.json @@ -0,0 +1,82 @@ +{ + "data": { + "event_id": 18925, + "markets": [ + { + "category_name": "Game Lines", + "display_name": "Moneyline", + "id": 219, + "name": "Moneyline", + "selections": [ + { + "competitor_id": 11, + "display_name": "Detroit Lions -470", + "display_price": "-470", + "strike": 0, + "strike_id": "90de769f4f4977a04446ed203e957351", + "name": "Detroit Lions", + "price": -470, + "outcome_id": 4, + "quantity": 100, + "updated_at": 1736126912002271000, + "value": 470 + }, + { + "competitor_id": 20, + "display_name": "Minnesota Vikings -136", + "display_price": "-136", + "strike": 0, + "strike_id": "7d75b0c55f5578219f534413d0bd872b", + "name": "Minnesota Vikings", + "price": -136, + "outcome_id": 5, + "quantity": 100, + "updated_at": 1736127082575143000, + "value": 71.06 + } + ], + "sub_type": "moneyline", + "type": "moneyline" + }, + { + "category_name": "Game Lines", + "display_name": "Spread", + "id": 223, + "strike": -5.5, + "name": "Fixed home -5.5", + "selections": [ + { + "competitor_id": 11, + "display_strike": "-5.5", + "display_name": "Detroit Lions -5.5", + "display_price": "-112", + "strike": -5.5, + "strike_id": "b288cdeffc0f3ba7e384009a3502543f", + "name": "Detroit Lions -5.5", + "price": -112, + "outcome_id": 1714, + "quantity": 200, + "updated_at": 1736127295209374000, + "value": 112 + }, + { + "competitor_id": 20, + "display_strike": "+5.5", + "display_name": "Minnesota Vikings +5.5", + "display_price": "-112", + "strike": 5.5, + "strike_id": "52882b69700724c6332375283b6e7f2a", + "name": "Minnesota Vikings +5.5", + "price": -112, + "outcome_id": 1715, + "quantity": 100, + "updated_at": 1736127190680127000, + "value": 12 + } + ], + "sub_type": "spread", + "type": "spread" + } + ] + } +} diff --git a/tests/samples/prophetx_markets_v4.json b/tests/samples/prophetx_markets_v4.json new file mode 100644 index 0000000..955b167 --- /dev/null +++ b/tests/samples/prophetx_markets_v4.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "id": 555, + "name": "Moneyline", + "display_name": "Moneyline", + "type": "moneyline", + "category_name": "Game Lines", + "sub_type": "moneyline", + "selections": [ + [ + { + "outcome_id": 1, + "name": "Patriots", + "price": -110, + "strike": null, + "strike_id": "line_1", + "quantity": 2100.0 + } + ], + [ + { + "outcome_id": 2, + "name": "Jets", + "price": 105, + "strike": null, + "strike_id": "line_2", + "quantity": 2150.0 + } + ] + ] + } + ] +} diff --git a/tests/samples/prophetx_sport_events.json b/tests/samples/prophetx_sport_events.json new file mode 100644 index 0000000..94f6e74 --- /dev/null +++ b/tests/samples/prophetx_sport_events.json @@ -0,0 +1,34 @@ +{ + "data": { + "sport_events": [ + { + "competitors": [ + { + "abbreviation": "DET", + "display_name": "Detroit Lions", + "id": 11, + "name": "Detroit Lions", + "side": "home" + }, + { + "abbreviation": "MIN", + "display_name": "Minnesota Vikings", + "id": 20, + "name": "Minnesota Vikings", + "side": "away" + } + ], + "display_name": "Minnesota Vikings at Detroit Lions", + "event_id": 18925, + "live_disabled": false, + "name": "Minnesota Vikings at Detroit Lions", + "scheduled": "2025-01-06T01:20:00Z", + "sport_name": "American Football", + "status": "live", + "tournament_id": 31, + "tournament_name": "NFL", + "updated_at": 1736126653111674000 + } + ] + } +} diff --git a/tests/samples/prophetx_tournaments.json b/tests/samples/prophetx_tournaments.json new file mode 100644 index 0000000..ebd340b --- /dev/null +++ b/tests/samples/prophetx_tournaments.json @@ -0,0 +1,45 @@ +{ + "data": { + "tournaments": [ + { + "category": { + "countryCode": "N/A", + "name": "N/A" + }, + "id": 2568, + "name": "French Open (M)", + "sport": { + "id": 5, + "name": "Tennis" + }, + "updated_at": 1734425801889462000 + }, + { + "category": { + "countryCode": "N/A", + "name": "N/A" + }, + "id": 2739, + "name": "ATP Indian Wells", + "sport": { + "id": 5, + "name": "Tennis" + }, + "updated_at": 1735116943350715000 + }, + { + "category": { + "countryCode": "N/A", + "name": "N/A" + }, + "id": 4221, + "name": "WTA Doha", + "sport": { + "id": 5, + "name": "Tennis" + }, + "updated_at": 1735116943385637000 + } + ] + } +} diff --git a/tests/samples/prophetx_ws.json b/tests/samples/prophetx_ws.json new file mode 100644 index 0000000..0ccd7db --- /dev/null +++ b/tests/samples/prophetx_ws.json @@ -0,0 +1,124 @@ +{ + "connection_config": { + "app_id": "", + "key": "", + "cluster": "", + "ws_host": "", + "service": "pusher" + }, + "connection_established": { + "event": "pusher:connection_established", + "data": "{\"socket_id\":\"123.456\"}" + }, + "register": { + "auth": ":", + "data": { + "success": true, + "status": "CONNECTED", + "updated_at": 1784813285, + "subscriptions": [ + { + "type": "tournament", + "ids": [ + "109" + ] + } + ], + "authorized_channel": [ + { + "channel_name": "private-broadcast-service=6-device_type=5", + "auth": ":", + "binding_events": [ + { + "name": "tournament_109", + "type": "tournament" + }, + { + "name": "general", + "type": "general" + } + ] + }, + { + "channel_name": "private-service=6-device_type=5-user=", + "auth": ":", + "binding_events": [ + { + "name": "orders", + "type": "orders" + }, + { + "name": "health_check", + "type": "private_system_signal" + } + ] + }, + { + "channel_name": "private-broadcast-service=6-device_type=5-event=10078829", + "auth": ":", + "binding_events": [ + { + "name": "market_selections", + "type": "market_selections" + }, + { + "name": "v2.market_selections", + "type": "market_selections" + } + ], + "scope": { + "event_id": 10078829 + } + }, + { + "channel_name": "private-broadcast-service=6-device_type=5-event=10078829-subtype=player_total_hits", + "auth": ":", + "binding_events": [ + { + "name": "market_selections", + "type": "market_selections" + }, + { + "name": "v2.market_selections", + "type": "market_selections" + } + ], + "scope": { + "event_id": 10078829, + "sub_type": "player_total_hits" + } + } + ], + "channel_count": 4, + "channel_limit": 400, + "authenticated": { + "auth": ":", + "user_data": "{\"id\":\"\"}" + } + } + }, + "trade_execution": { + "info": { + "strike": 0, + "strike_id": "cffa7ddc4fd50acbe51684e7addabe00", + "market_id": 219, + "fill_price": 124, + "filled_quantity": 45.3, + "price": 124, + "origin_market_strike": 0, + "outcome_id": 4, + "sequence_number": 1709130007835319000, + "sport_event_id": 44285780, + "aggressive": true + } + }, + "health_check": { + "event": "health_check", + "data": { + "change_type": "private_system_signal", + "op": "u", + "payload": "e30=", + "timestamp": 1717689526365275600 + } + } +} diff --git a/tests/test_init.py b/tests/test_init.py index 775d89a..a9bb92b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -42,7 +42,8 @@ def test_it_writes_every_venue_asked_for_readable_by_the_owner_only(tmp_path: Pa "n", # Limitless "n", # SX Bet "n", # Betfair - "n"], # Smarkets + "n", # Smarkets + "n"], # ProphetX secrets=[KEY, "us-secret"], ) assert run(env, p) == 0 @@ -64,7 +65,7 @@ def test_wrong_values_are_asked_again_with_the_reason(tmp_path: Path): p, said = scripted( ["y", "", "kid-1", str(tmp_path / "missing.pem"), str(not_pem), str(pem), "staging", "demo", "y", "0x1234", WALLET, "7", "1", - "n", "n", "n", "n", "n", "n", "n", "n"], + "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -81,7 +82,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) env = tmp_path / ".env" env.write_text("# mine\nSOMETHING_ELSE=keep\nPOLYMARKET_US_KEY_ID=old\n") os.chmod(env, 0o644) - p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) + p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) assert run(env, p) == 0 text = env.read_text() assert "# mine" in text and "SOMETHING_ELSE=keep" in text @@ -91,7 +92,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) def test_saying_no_to_everything_writes_nothing(tmp_path: Path): - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) assert run(tmp_path / ".env", p) == 0 assert not (tmp_path / ".env").exists() and any("Nothing changed." in line for line in said) @@ -104,7 +105,7 @@ def test_merge_quotes_values_with_spaces_and_updates_in_place(): @pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) + p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) assert run(tmp_path / ".env", p) == 0 assert (tmp_path / ".gitignore").read_text() == ".env\n" ignored = subprocess.run(["git", "check-ignore", "-q", ".env"], cwd=tmp_path) @@ -113,7 +114,7 @@ def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): def test_opinion_takes_a_key_it_is_given(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["OPINION_PRIVATE_KEY"] == KEY and values["OPINION_API_KEY"] == "opinion-api-key" @@ -122,7 +123,7 @@ def test_opinion_takes_a_key_it_is_given(tmp_path: Path): def test_opinion_with_an_api_key_needs_no_private_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"OPINION_API_KEY": "opinion-api-key"} assert load_credentials({}, dotenv=env, redact_logs=False)["opinion"] is None, "not enough to trade" @@ -135,7 +136,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch signed_with: list[str] = [] monkeypatch.setattr(opinion, "create_api_key", lambda key: signed_with.append(key) or "created-key") - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) assert run(tmp_path / ".env", p) == 0 assert signed_with == [KEY], "creating a key needs the private key: an empty one is asked again" assert read_dotenv(tmp_path / ".env")["OPINION_API_KEY"] == "created-key" @@ -144,7 +145,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY and values["HYPERLIQUID_ACCOUNT_ADDRESS"] == WALLET @@ -156,7 +157,7 @@ def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY @@ -165,7 +166,7 @@ def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_PRIVATE_KEY"] == KEY and values["PREDICT_FUN_ACCOUNT_ADDRESS"] == WALLET @@ -178,7 +179,7 @@ def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"PREDICT_FUN_API_KEY": "pred_sk_test"} text = " ".join(said) @@ -190,7 +191,7 @@ def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_TESTNET"] == "1" and "PREDICT_FUN_API_KEY" not in values @@ -201,7 +202,7 @@ def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): env = tmp_path / ".env" secret = "c2VjcmV0LWJ5dGVz" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n"], secrets=[KEY[2:], secret]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) assert run(env, p) == 0 values = read_dotenv(env) assert values["LIMITLESS_PRIVATE_KEY"] == KEY and values["LIMITLESS_API_TOKEN_ID"] == "token-123" @@ -212,7 +213,7 @@ def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n"], secrets=["sx-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["SX_BET_PRIVATE_KEY"] == KEY and values["SX_BET_API_KEY"] == "sx-api-key" @@ -223,7 +224,7 @@ def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n"], secrets=["sx-api-key", ""]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key", "SX_BET_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said), "a key pasted as the address is refused" @@ -234,7 +235,7 @@ def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): def test_sx_bet_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n"], secrets=["sx-api-key", ""]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key"} loaded = load_credentials({}, dotenv=env, redact_logs=False)["sx_bet"] diff --git a/tests/test_prophetx.py b/tests/test_prophetx.py new file mode 100644 index 0000000..edff550 --- /dev/null +++ b/tests/test_prophetx.py @@ -0,0 +1,375 @@ +"""ProphetX normalizers, session and adapter. Every ProphetX route needs a key, +so the samples are the examples in ProphetX's own documentation +(docs.prophetx.co), copied unedited: the market data guide's v4 markets +(nested selections, `quantity` only) and the auto-fill guide's tournaments, +events and v3 markets (flat selections, with `value`).""" +from __future__ import annotations + +import json +from fractions import Fraction + +import httpx +import pytest + +from synpath import AuthenticationError, BadRequest, MarketNotFound, NotSupported +from synpath.prophetx import ( + ProphetX, ProphetXSession, american_of, implied_probability, lines_of, markets_by_event, markets_of, + normalize_event, normalize_market, normalize_order_book, parse_line_id, probability, status_of, +) +from synpath.trading.credentials import CredentialsMissing, ProphetXCredentials, load_prophetx + +from conftest import load + +TOURNAMENTS = load("prophetx_tournaments.json") +EVENTS = load("prophetx_sport_events.json") +V3 = load("prophetx_markets_v3.json") +V4 = load("prophetx_markets_v4.json") +AUTH = load("prophetx_auth.json") +EVENT = EVENTS["data"]["sport_events"][0] +LIONS = "90de769f4f4977a04446ed203e957351" +VIKINGS = "7d75b0c55f5578219f534413d0bd872b" + + +class TestPrices: + @pytest.mark.parametrize("american, exact", [ + (-110, Fraction(11, 21)), (120, Fraction(5, 11)), (-470, Fraction(47, 57)), (-136, Fraction(34, 59)), + (100, Fraction(1, 2)), (-100, Fraction(1, 2)), (10000, Fraction(1, 101)), (-10000, Fraction(100, 101)), + ("-112", Fraction(28, 53)), (105.0, Fraction(20, 41)), + ]) + def test_american_odds_are_exact_probabilities(self, american, exact): + assert implied_probability(american) == exact + + @pytest.mark.parametrize("american", [None, 0, 50, -99, "x", True]) + def test_prices_american_odds_do_not_have(self, american): + assert implied_probability(american) is None and probability(american) is None + + def test_a_read_price_is_eight_places(self): + assert probability(-110) == 0.52380952 and probability(120) == 0.45454545 + + @pytest.mark.parametrize("american", [-10000, -470, -136, -101, 100, 101, 124, 2500, 10000]) + def test_american_of_inverts_exactly(self, american): + assert american_of(implied_probability(american)) == american + + def test_american_of_refuses_certainty(self): + with pytest.raises(BadRequest): + american_of(Fraction(1)) + + +class TestLines: + def test_nested_sides_make_one_line(self): + [line] = lines_of(V4["data"][0]) + assert (line["_yes"], line["_no"]) == ("line_1", "line_2") + + def test_flat_sides_make_one_line_each(self): + lines = [line for m in markets_of(V3) for line in lines_of(m)] + assert [(ln["_yes"], ln["_no"]) for ln in lines] == [ + (LIONS, VIKINGS), ("b288cdeffc0f3ba7e384009a3502543f", "52882b69700724c6332375283b6e7f2a")] + + def test_a_three_way_market_is_not_binary(self): + three = {**V4["data"][0], "type": "moneyline_3_way", + "selections": V4["data"][0]["selections"] + [[{"strike_id": "draw", "price": 250}]]} + assert lines_of(three) == [] + + def test_each_market_strike_is_its_own_line(self): + market = {"id": 9, "name": "Spread", "type": "spread", "market_strikes": [ + {"strike": -3.5, "selections": [[{"strike_id": "a", "price": -110}], [{"strike_id": "b", "price": -110}]]}, + {"strike": -4.5, "selections": [[{"strike_id": "c", "price": 120}], [{"strike_id": "d", "price": -140}]]}, + ]} + lines = lines_of(market) + assert [(ln["_yes"], ln["strike"], ln["id"]) for ln in lines] == [("a", -3.5, 9), ("c", -4.5, 9)] + + def test_multiple_markets_keyed_or_flat(self): + keyed = markets_by_event({"data": {"101": V4["data"], "102": []}}) + flat = markets_by_event({"data": [{**V4["data"][0], "event_id": 101}]}) + assert set(keyed) == {"101", "102"} and len(keyed["101"]) == 1 + assert list(flat) == ["101"] + + +class TestMarket: + def test_a_line_is_named_by_its_event_and_yes_strike(self): + [line] = lines_of(V4["data"][0]) + market = normalize_market({"event_id": 101, "name": "Patriots vs. Jets"}, line) + assert market.id == "prophetx:101-line_1" and market.event_id == "prophetx:101" + assert (market.yes.label, market.no.label) == ("Patriots", "Jets") + assert market.yes.venue_token_id == "line_1" and market.info["no_strike_id"] == "line_2" + assert market.title == "Patriots vs. Jets — Moneyline — Patriots" + assert market.book_model == "shared_complement" and market.tick_size is None + + def test_side_one_is_the_yes_ask_and_side_two_the_yes_bid(self): + [line] = lines_of(V4["data"][0]) + market = normalize_market({"event_id": 101}, line) + # Patriots -110: YES at 110/210. Jets +105: NO at 100/205, YES bid one minus it. + assert market.yes.quote.ask == 0.52380952 and market.yes.quote.bid == 0.51219512 + assert market.no.quote.ask == pytest.approx(1 - 0.51219512) and market.no.quote.bid == pytest.approx(1 - 0.52380952) + # No `value` on these levels: the size falls back to `quantity`, in contracts. + assert market.yes.quote.ask_size == pytest.approx(2100 * 210 / 110, rel=1e-6) + + def test_a_levels_value_is_the_stake_it_takes(self): + [moneyline] = lines_of(markets_of(V3)[0]) + market = normalize_market(EVENT, moneyline) + # Lions -470 can take $470: it returns 470 + 100 = 570 contracts. + assert market.yes.quote.ask == probability(-470) and market.yes.quote.ask_size == pytest.approx(570, rel=1e-6) + assert market.yes.quote.bid == pytest.approx(1 - 136 / 236) + assert market.yes.quote.bid_size == pytest.approx(71.06 * 236 / 136, rel=1e-6) + + def test_the_event_gives_tags_and_series(self): + market = normalize_market(EVENT, lines_of(markets_of(V3)[1])[0]) + assert market.tags == ["american football", "nfl"] and market.series_id == "31" + assert market.info["strike"] == -5.5 and market.yes.label == "Detroit Lions -5.5" + assert market.status == "open" and market.active is True + + @pytest.mark.parametrize("state, expected", [ + ("not_started", ("open", True)), ("live", ("open", True)), ("suspended", ("open", False)), + ("interrupted", ("open", False)), ("ended", ("closed", False)), ("cancelled", ("closed", False)), + ]) + def test_statuses(self, state, expected): + assert status_of({"status": state}) == expected + + def test_placeholder_levels_are_not_liquidity(self): + market = {"id": 1, "name": "Total", "selections": [ + [{"strike_id": "o", "name": "Over 48.5", "price": 0, "value": 0}], + [{"strike_id": "u", "name": "Under 48.5", "price": None}]]} + normalized = normalize_market({"event_id": 5}, lines_of(market)[0]) + assert normalized.yes.quote.bid is None and normalized.yes.quote.ask is None + assert normalized.id == "prophetx:5-o" + + +class TestEvent: + def test_an_event_holds_every_binary_line(self): + event = normalize_event(EVENT, markets_of(V3)) + assert event.id == "prophetx:18925" and event.title == "Minnesota Vikings at Detroit Lions" + assert [m.id for m in event.markets] == [f"prophetx:18925-{LIONS}", "prophetx:18925-b288cdeffc0f3ba7e384009a3502543f"] + assert event.status == "open" and event.series_id == "31" + assert [c["abbreviation"] for c in event.info["competitors"]] == ["DET", "MIN"] + + +class TestOrderBook: + def test_best_first_and_mirrored_for_no(self): + line = lines_of({"id": 1, "selections": [ + [{"strike_id": "a", "price": -110, "value": 110}, {"strike_id": "a", "price": -120, "value": 60}], + [{"strike_id": "b", "price": 100, "value": 50}, {"strike_id": "b", "price": -105, "value": 21}]]})[0] + yes = normalize_order_book(line, market_id="prophetx:1-a") + assert [lv.price for lv in yes.asks] == [probability(-110), probability(-120)] + assert [lv.price for lv in yes.bids] == [0.5, round(1 - probability(-105), 8)] + assert yes.asks[0].size == pytest.approx(210) and yes.bids[0].size == pytest.approx(100) + no = normalize_order_book(line, market_id="prophetx:1-a", side="no", depth=1) + assert no.derived and no.asks[0].price == 0.5 and no.bids[0].price == pytest.approx(1 - probability(-110)) + assert len(no.asks) == len(no.bids) == 1 and yes.depth_scope == "top_n" + + def test_an_unknown_side_is_refused(self): + with pytest.raises(BadRequest): + normalize_order_book(lines_of(V4["data"][0])[0], market_id="x", side="maybe") # type: ignore[arg-type] + + +class TestIds: + def test_a_market_id_is_event_and_strike(self): + assert parse_line_id("18925-" + LIONS) == ("18925", LIONS) + + @pytest.mark.parametrize("bad", ["18925", "abc-def", "-x", LIONS]) + def test_anything_else_is_refused(self, bad): + with pytest.raises(BadRequest): + parse_line_id(bad) + + +class FakeHttp: + """Answers GETs by path, and records them.""" + + def __init__(self, answers): + self.calls: list[tuple[str, object]] = [] + self.answers = answers + + def get(self, path, params=None): + self.calls.append((path, params)) + if path in self.answers: + answer = self.answers[path] + return answer(params) if callable(answer) else answer + raise MarketNotFound(f"prophetx: no {path}") + + def close(self): + pass + + +def venue_with(answers, **kwargs) -> ProphetX: + venue = ProphetX(limiter=None, affiliate_key="aff", **kwargs) + venue.http = FakeHttp(answers) + return venue + + +NFL = {"data": {"tournaments": [{"id": 31, "name": "NFL"}, {"id": 132, "name": "NBA"}]}} + + +def events_answer(params): + tournament = dict(params or []).get("tournament_id") + return EVENTS if tournament in (None, "31") else {"data": {"sport_events": []}} + + +ANSWERS = { + "/affiliate/get_tournaments": NFL, + "/affiliate/get_sport_events": events_answer, + "/v4/affiliate/get_markets": V3, +} + + +class TestAdapter: + def test_markets_walk_tournaments_events_and_markets(self): + venue = venue_with(ANSWERS) + page = venue.fetch_markets(limit=1) + assert [m.id for m in page] == [f"prophetx:18925-{LIONS}"] and page.next_cursor == "0:1" + rest = venue.fetch_markets(limit=5, cursor=page.next_cursor) + assert [m.id for m in rest] == ["prophetx:18925-b288cdeffc0f3ba7e384009a3502543f"] and rest.next_cursor is None + assert ("/affiliate/get_tournaments", {"has_active_events": "true"}) in venue.http.calls + + def test_the_trading_api_reads_the_same_on_its_own_routes(self): + venue = ProphetX(limiter=None, session=ProphetXSession(access_key="a", secret_key="s")) + venue.http = FakeHttp({k.replace("affiliate", "mm"): v for k, v in ANSWERS.items()}) + assert venue.api == "mm" and len(venue.fetch_markets()) == 2 + + def test_query_matches_event_and_competitor_names(self): + venue = venue_with(ANSWERS) + assert len(venue.fetch_markets(query="vikings")) == 2 + assert venue.fetch_markets(query="chiefs") == [] + + def test_events_page_by_tournament(self): + venue = venue_with(ANSWERS) + page = venue.fetch_events() + assert [e.id for e in page] == ["prophetx:18925"] and page.next_cursor == "1:0" + assert venue.fetch_events(cursor=page.next_cursor) == [] + + def test_only_open_events_are_listed(self): + venue = venue_with(ANSWERS) + with pytest.raises(NotSupported): + venue.fetch_markets(status="settled") + with pytest.raises(BadRequest): + venue.fetch_markets(cursor="nonsense") + + def test_markets_by_id_and_books(self): + venue = venue_with(ANSWERS) + [market] = venue.fetch_markets_by_ids([f"prophetx:18925-{LIONS}", "prophetx:18925-gone"]) + assert market.yes.label == "Detroit Lions" and market.title.startswith("Minnesota Vikings at Detroit Lions") + book = venue.fetch_order_book(f"prophetx:18925-{LIONS}") + assert book.asks[0].price == probability(-470) + assert set(venue.fetch_order_books(["prophetx:18925"])) == { + f"prophetx:18925-{LIONS}", "prophetx:18925-b288cdeffc0f3ba7e384009a3502543f"} + with pytest.raises(MarketNotFound): + venue.fetch_order_book("prophetx:18925-gone") + + def test_many_events_are_read_in_one_request(self): + venue = venue_with({"/v4/affiliate/get_multiple_markets": {"data": {"18925": markets_of(V3), "7": []}}}) + books = venue.fetch_order_books([f"prophetx:18925-{LIONS}", "prophetx:7-x"]) + assert list(books) == [f"prophetx:18925-{LIONS}"] + [(path, params)] = venue.http.calls + assert path == "/v4/affiliate/get_multiple_markets" and params == [("event_ids", "18925"), ("event_ids", "7")] + + def test_what_the_venue_does_not_offer(self): + venue = venue_with({}) + for call in (venue.fetch_trades, venue.fetch_ohlcv, venue.fetch_fee_schedule): + with pytest.raises(NotSupported): + call(f"prophetx:18925-{LIONS}") + + +def transport(log: list, *, refresh_ok: bool = True, expire_first: bool = False): + """A ProphetX that logs in, refreshes, and answers markets with a token.""" + state = {"login": 0, "refresh": 0, "rejected": False} + + def handler(request: httpx.Request) -> httpx.Response: + log.append(request) + path = request.url.path + if path.endswith("/auth/login"): + state["login"] += 1 + body = json.loads(request.content) + assert body == {"access_key": "acc", "secret_key": "sec"} + return httpx.Response(200, json={"data": {**AUTH["data"], "access_token": f"tok{state['login']}", + "refresh_token": "ref"}}) + if path.endswith("/auth/refresh"): + state["refresh"] += 1 + if not refresh_ok: + return httpx.Response(401, json={"error": "unauthorized", "message": "token expired"}) + return httpx.Response(200, json={"data": {"access_token": f"fresh{state['refresh']}"}}) + if expire_first and not state["rejected"]: + state["rejected"] = True + return httpx.Response(401, json={"error": "unauthorized", "message": "token expired"}) + return httpx.Response(200, json=V4) + + return httpx.MockTransport(handler), state + + +class TestSession: + def test_a_login_then_bearer_tokens(self): + log: list = [] + mock, state = transport(log) + clock = [1000.0] + session = ProphetXSession(access_key="acc", secret_key="sec", client=httpx.Client(transport=mock), + clock=lambda: clock[0]) + venue = ProphetX(limiter=None, session=session, client=httpx.Client(transport=mock)) + venue.fetch_event_markets(["101"]) + assert log[-1].headers["authorization"] == "Bearer tok1" and log[-1].url.path == "/partner/v4/mm/get_markets" + clock[0] += 9 * 60 + venue.fetch_event_markets(["101"]) + assert state == {"login": 1, "refresh": 1, "rejected": False} + assert log[-1].headers["authorization"] == "Bearer fresh1" + + def test_a_refused_refresh_logs_in_again(self): + log: list = [] + mock, state = transport(log, refresh_ok=False) + clock = [1000.0] + session = ProphetXSession(access_key="acc", secret_key="sec", client=httpx.Client(transport=mock), + clock=lambda: clock[0]) + session.token() + clock[0] += 9 * 60 + assert session.token() == "tok2" and state["login"] == 2 and state["refresh"] == 1 + + def test_a_401_renews_the_token_once(self): + log: list = [] + mock, state = transport(log, expire_first=True) + session = ProphetXSession(access_key="acc", secret_key="sec", client=httpx.Client(transport=mock)) + venue = ProphetX(limiter=None, session=session, client=httpx.Client(transport=mock)) + assert venue.fetch_event_markets(["101"])["101"] + assert state["refresh"] == 1 and log[-1].headers["authorization"] == "Bearer fresh1" + + def test_a_refused_login_is_an_authentication_error(self): + def refuse(request): + return httpx.Response(401, json={"error": "invalid_credentials", "message": "no"}) + + session = ProphetXSession(access_key="acc", secret_key="sec", client=httpx.Client(transport=httpx.MockTransport(refuse))) + with pytest.raises(AuthenticationError, match="invalid_credentials"): + session.token() + + def test_the_affiliate_key_goes_raw(self): + log: list = [] + + def answer(request): + log.append(request) + return httpx.Response(200, json=TOURNAMENTS) + + venue = ProphetX(limiter=None, affiliate_key="aff-key", client=httpx.Client(transport=httpx.MockTransport(answer))) + assert [t["id"] for t in venue.fetch_tournaments()] == [2568, 2739, 4221] + assert log[0].headers["authorization"] == "aff-key" and log[0].url.path == "/partner/affiliate/get_tournaments" + + def test_without_a_key_nothing_is_sent(self): + venue = ProphetX(limiter=None, login=False, client=httpx.Client(transport=httpx.MockTransport( + lambda r: pytest.fail("sent without a key")))) + with pytest.raises(AuthenticationError, match="PROPHETX_ACCESS_KEY"): + venue.fetch_tournaments() + + def test_the_sandbox(self): + venue = ProphetX(limiter=None, credentials=ProphetXCredentials(affiliate_key="k", sandbox=True)) + assert venue.http.base_url == "https://api.sandbox.prophetx.dev/partner" + + +class TestCredentials: + def test_the_key_pair_and_the_affiliate_key(self): + creds = load_prophetx({"PROPHETX_ACCESS_KEY": "acc-123", "PROPHETX_SECRET_KEY": "sec-456", + "PROPHETX_AFFILIATE_KEY": "aff-789", "PROPHETX_SANDBOX": "1"}) + assert creds.trades and creds.sandbox and creds.secrets == ["acc-123", "sec-456", "aff-789"] + assert not any(v in repr(creds) for v in creds.secrets) and "secrets=***" in repr(creds) + + def test_the_affiliate_key_alone_reads_market_data(self): + creds = load_prophetx({"PROPHETX_AFFILIATE_KEY": "k"}) + assert creds.affiliate_key == "k" and not creds.trades + + def test_nothing_set_is_not_configured(self): + assert load_prophetx({}) is None + + def test_half_a_key_pair_is_refused(self): + with pytest.raises(CredentialsMissing, match="PROPHETX_SECRET_KEY"): + load_prophetx({"PROPHETX_ACCESS_KEY": "a"}) diff --git a/tests/test_prophetx_trading.py b/tests/test_prophetx_trading.py new file mode 100644 index 0000000..843ebde --- /dev/null +++ b/tests/test_prophetx_trading.py @@ -0,0 +1,410 @@ +"""ProphetX order entry: order bodies, records, typed refusals, and the +adapter against a scripted venue. Records carry the fields of ProphetX's +Trading API reference (docs.prophetx.co: `get_order`, `get_trades`, +`get_order_matched_detail`, the `orders` WebSocket event) with values in the +reference's examples; they need an account to record.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +import httpx +import pytest + +from synpath.prophetx import lines_of, markets_of +from synpath.trading.credentials import CredentialsMissing, ProphetXCredentials +from synpath.trading.errors import DuplicateClientOrderId, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected +from synpath.trading.prophetx import ( + ProphetXTrading, build_order, contracts_of, error_of, fill_of_row, ladder_price, order_of_row, status_of, +) +from synpath.trading.types import ( + Liquidity, OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce, +) +from synpath.errors import BadRequest + +from conftest import load + +V3 = load("prophetx_markets_v3.json") +AUTH = load("prophetx_auth.json") +BALANCE = load("prophetx_balance.json") +LIONS = "90de769f4f4977a04446ed203e957351" +VIKINGS = "7d75b0c55f5578219f534413d0bd872b" +MARKET_ID = f"prophetx:18925-{LIONS}" +LADDER = [-10000, -500, -470, -200, -150, -136, -120, -112, -110, -105, -101, 100, 101, 105, 110, 120, 124, 150, + 200, 500, 10000] +ORDER_ROW = { + "order_id": "partner_6b0d2e8c-0f5e-4bd4-9a51-2b0f6c1b7a10", "external_id": "c-1", "strike_id": LIONS, + "sport_event_id": 18925, "market_id": 219, "outcome_id": 4, "price": -110, "strike": 0, + "quantity": 11.0, "original_quantity": 11.0, "filled_quantity": 5.5, "open_quantity": 5.5, "profit": 10.0, + "matching_status": "partially_matched", "status": "open", "winning_status": "tbd", + "user_id": "550e8400-e29b-41d4-a716-446655440000", "created_at": "2026-08-10T14:00:00Z", + "updated_at": "2026-08-10T14:00:01Z", "fee": 0.05, +} +TRADE_ROW = {"id": 77, "order_id": ORDER_ROW["order_id"], "price": 124, "quantity": 10.0, "profit": 12.4, + "strike": 0, "strike_id": VIKINGS, "status": "open", "winning_status": "tbd", + "matched_at": "2026-08-10T14:00:02Z"} + + +def request(**changes) -> OrderRequest: + base = {"market_id": MARKET_ID, "side": Side.BUY, "quantity": Decimal("21"), "price": Decimal("0.53")} + return OrderRequest(**{**base, **changes}) + + +class TestOrders: + def test_a_buy_is_side_one_on_the_ladder_on_the_cheaper_side(self): + # 0.53 sits between -112 (0.5283) and -115: the dearest price at or below it. + body = build_order(request(client_order_id="c-1"), strike_id=LIONS, ladder=LADDER) + assert body == {"external_id": "c-1", "strike_id": LIONS, "price": -112, "quantity": 11.09} + + def test_the_stake_is_the_contracts_at_the_ladder_price_rounded_down(self): + body = build_order(request(price=Decimal("0.52381")), strike_id=LIONS, ladder=LADDER) + assert body["price"] == -110 and body["quantity"] == 11.0 # 21 * 110/210 + # A hair below -110's 11/21 is not -110: the limit is never exceeded. + assert build_order(request(price=Decimal("0.5238095")), strike_id=LIONS, ladder=LADDER)["price"] == -105 + + def test_a_sell_buys_side_two_at_one_minus_the_price(self): + body = build_order(request(side=Side.SELL, price=Decimal("0.55")), strike_id=VIKINGS, ladder=LADDER) + # NO at 0.45 at most: +124 is 0.4464. + assert body["strike_id"] == VIKINGS and body["price"] == 124 + + @pytest.mark.parametrize("changes, strategy", [ + ({"type": OrderType.MARKET}, "fillOrKill"), + ({"time_in_force": TimeInForce.IOC}, "fillOrKill"), + ({"post_only": True}, "ALO"), + ({}, None), + ]) + def test_time_in_force(self, changes, strategy): + body = build_order(request(**changes), strike_id=LIONS, ladder=LADDER) + assert body.get("order_strategy") == strategy + + @pytest.mark.parametrize("changes", [ + {"time_in_force": TimeInForce.FOK}, + {"time_in_force": TimeInForce.GTD, "expires_at": 1_800_000_000_000}, + {"reduce_only": True}, + {"post_only": True, "time_in_force": TimeInForce.IOC}, + {"client_order_id": "has space"}, + {"quantity": Decimal("1")}, + {"type": OrderType.STOP_MARKET, "stop_price": Decimal("0.5")}, + ]) + def test_refused_before_sending(self, changes): + with pytest.raises(InvalidOrder): + build_order(request(**changes), strike_id=LIONS, ladder=LADDER) + + def test_no_ladder_price_below_the_limit(self): + with pytest.raises(InvalidOrder, match="ladder"): + ladder_price(Decimal("0.005"), LADDER) + + def test_an_external_id_is_made_when_none_is_given(self): + body = build_order(request(), strike_id=LIONS, ladder=LADDER) + assert len(body["external_id"]) == 32 + + +class TestRecords: + def test_an_order_row_in_contracts_on_the_yes_leg(self): + order = order_of_row(ORDER_ROW, market_id=MARKET_ID, is_yes=True) + assert (order.id, order.client_order_id, order.status) == (ORDER_ROW["order_id"], "c-1", OrderStatus.OPEN) + assert order.price == Decimal(11) / Decimal(21) and order.side == Side.BUY + assert (order.quantity, order.filled, order.remaining) == (Decimal("21"), Decimal("10.5"), Decimal("10.5")) + assert order.fee == Decimal("0.05") and order.created_at == 1786370400000 + + def test_a_side_two_order_is_a_sell_at_one_minus_its_price(self): + order = order_of_row({**ORDER_ROW, "strike_id": VIKINGS, "price": 124}, market_id=MARKET_ID, is_yes=False) + assert order.side == Side.SELL and order.price == 1 - Decimal(100) / Decimal(224) + + @pytest.mark.parametrize("changes, status", [ + ({"status": "inactive"}, OrderStatus.PENDING), + ({"status": "invalid"}, OrderStatus.REJECTED), + ({"status": "canceled", "filled_quantity": 0}, OrderStatus.CANCELED), + ({"status": "wiped"}, OrderStatus.CANCELED), + ({"status": "open", "matching_status": "unmatched"}, OrderStatus.OPEN), + ({"status": "open", "matching_status": "fully_matched", "quantity": 11.0, "filled_quantity": 11.0}, OrderStatus.CLOSED), + # A partly filled order whose rest was cancelled is rewritten smaller. + ({"status": "open", "matching_status": "fully_matched", "quantity": 5.5}, OrderStatus.CANCELED), + ({"status": "closed"}, OrderStatus.CLOSED), + ({"status": "closed", "filled_quantity": 0}, OrderStatus.CANCELED), + ]) + def test_statuses(self, changes, status): + assert status_of({**ORDER_ROW, **changes}) == status + + def test_a_partial_whose_rest_was_cancelled_keeps_its_fill(self): + order = order_of_row({**ORDER_ROW, "matching_status": "fully_matched", "quantity": 5.5, "open_quantity": 0}, + market_id=MARKET_ID, is_yes=True) + assert order.status == OrderStatus.CANCELED and order.filled == Decimal("10.5") and order.remaining == 0 + + def test_a_fill_is_its_stake_plus_its_profit(self): + fill = fill_of_row(TRADE_ROW, market_id=MARKET_ID, is_yes=False) + assert fill.quantity == Decimal("22.4") == contracts_of(10, 124) + assert fill.side == Side.SELL and fill.price == 1 - Decimal(100) / Decimal(224) and fill.id == "77" + + def test_a_stream_fill(self): + row = {**ORDER_ROW, "id": ORDER_ROW["order_id"], "update_type": "matching", "fill_price": -105, + "filled_quantity": 2.1, "total_filled_quantity": 7.6, "matching_id": 991, "aggressive": True} + fill = fill_of_row(row, market_id=MARKET_ID, is_yes=True) + assert fill.id == "991" and fill.quantity == Decimal("4.1") and fill.liquidity == Liquidity.TAKER + order = order_of_row(row, market_id=MARKET_ID, is_yes=True, stream=True) + assert order.filled == contracts_of(7.6, -110) and order.last_fill_quantity == Decimal("4.1") + + @pytest.mark.parametrize("code, error", [ + ("order_external_id_existed", DuplicateClientOrderId), + ("invalid_price", InvalidOrder), + ("order_invalid_profit", InvalidOrder), + ("market_is_only_for_prematch", MarketHalted), + ("sport_event_is_not_booked", MarketHalted), + ("order_unable_to_place", OrderRejected), + ]) + def test_refusals_are_typed(self, code, error): + exc = BadRequest(f"prophetx: {code}", body={"error": code, "message": "m"}, status=400) + assert isinstance(error_of(exc), error) + + +class Venue: + """Answers requests by `METHOD /path` (below `/partner`), and records them.""" + + def __init__(self, **answers): + self.answers = { + "POST /auth/login": {"data": {**AUTH["data"], "access_token": "tok", "refresh_token": "ref"}}, + "GET /v4/mm/get_price_ladder": {"data": LADDER}, + **answers, + } + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + key = f"{req.method} {req.url.path.removeprefix('/partner')}" + answer = self.answers.get(key) + if answer is None: + return httpx.Response(404, json={"error": "data_not_found", "message": "not found"}) + if callable(answer): + answer = answer(req) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + def sent(self, method: str, path: str) -> list[httpx.Request]: + return [r for r in self.requests if r.method == method and r.url.path == "/partner" + path] + + +class Catalog: + """The read adapter's lines for event 18925, from the documented markets.""" + + def __init__(self): + self.asked: list[str] = [] + + def lines(self, event_id): + self.asked.append(event_id) + return [{**ln, "event_id": event_id} for m in markets_of(V3) for ln in lines_of(m)] if event_id == "18925" else [] + + def close(self): + pass + + +CREDS = ProphetXCredentials(access_key="acc", secret_key="sec") + + +def adapter(venue: Venue) -> ProphetXTrading: + return ProphetXTrading(CREDS, catalog=Catalog(), client=httpx.AsyncClient(transport=httpx.MockTransport(venue))) + + +def run(coro): + return asyncio.run(coro) + + +def placed(req): + body = json.loads(req.content) + return {"data": {"success": True, "order": { + "order_id": "partner_1", "external_id": body["external_id"], "strike_id": body["strike_id"], + "price": body["price"], "quantity": body["quantity"], "filled_quantity": 0, "open_quantity": body["quantity"], + "matching_status": "unmatched", "status": "inactive", "sport_event_id": 18925, "market_id": 219}}} + + +class TestAdapter: + def test_an_order_logs_in_snaps_and_comes_back_pending(self): + venue = Venue(**{"POST /v4/mm/submit_order": placed}) + + async def go(): + async with adapter(venue) as px: + return await px.create_order(request(client_order_id="c-1")) + + order = run(go()) + [login] = venue.sent("POST", "/auth/login") + assert json.loads(login.content) == {"access_key": "acc", "secret_key": "sec"} + [sent] = venue.sent("POST", "/v4/mm/submit_order") + assert sent.headers["authorization"] == "Bearer tok" + assert json.loads(sent.content) == {"external_id": "c-1", "strike_id": LIONS, "price": -112, "quantity": 11.09} + assert (order.id, order.status, order.market_id, order.side) == ("partner_1", OrderStatus.PENDING, MARKET_ID, Side.BUY) + + def test_a_sell_buys_the_lines_other_strike(self): + venue = Venue(**{"POST /v4/mm/submit_order": placed}) + + async def go(): + async with adapter(venue) as px: + return await px.create_order(request(side=Side.SELL, price=Decimal("0.55"))) + + order = run(go()) + assert json.loads(venue.sent("POST", "/v4/mm/submit_order")[0].content)["strike_id"] == VIKINGS + assert order.side == Side.SELL and order.market_id == MARKET_ID + + def test_a_refusal_is_typed_and_a_batch_keeps_it(self): + venue = Venue(**{"POST /v4/mm/submit_order": httpx.Response( + 400, json={"error": "order_external_id_existed", "message": "external id existed"})}) + + async def go(): + async with adapter(venue) as px: + return await px.create_orders([request(client_order_id="dup"), request(quantity=Decimal("1"))]) + + dup, small = run(go()) + assert isinstance(dup, DuplicateClientOrderId) and isinstance(small, InvalidOrder) + + def test_a_cancel_is_final_though_the_read_lags(self): + order = {**ORDER_ROW, "matching_status": "unmatched", "filled_quantity": 0} + venue = Venue(**{ + "POST /v4/mm/cancel_order": {"data": {"success": True}}, + f"GET /v4/mm/get_order/{ORDER_ROW['order_id']}": {"data": order}, + }) + + async def go(): + async with adapter(venue) as px: + return await px.cancel_order(ORDER_ROW["order_id"]) + + cancelled = run(go()) + [sent] = venue.sent("POST", "/v4/mm/cancel_order") + assert json.loads(sent.content) == {"external_id": "c-1", "order_id": ORDER_ROW["order_id"]} + assert cancelled.status == OrderStatus.CANCELED and cancelled.remaining == 0 + + def test_a_cancel_waits_out_placing_and_reads_a_filled_order_back(self): + answers = iter([httpx.Response(409, json={"error": "order_is_placing", "message": "placing"}), + httpx.Response(400, json={"error": "order_already_filled", "message": "filled"})]) + filled = {**ORDER_ROW, "matching_status": "fully_matched", "filled_quantity": 11.0, "open_quantity": 0} + venue = Venue(**{ + "POST /v4/mm/cancel_order": lambda req: next(answers), + f"GET /v4/mm/get_order/{ORDER_ROW['order_id']}": {"data": filled}, + }) + + async def go(): + async with adapter(venue) as px: + return await px.cancel_order(ORDER_ROW["order_id"]) + + order = run(go()) + assert len(venue.sent("POST", "/v4/mm/cancel_order")) == 2 and order.status == OrderStatus.CLOSED + + def test_an_unknown_order(self): + async def go(): + async with adapter(Venue()) as px: + return await px.fetch_order("partner_x") + + with pytest.raises(OrderNotFound): + run(go()) + + def test_cancel_all_on_the_account_or_both_sides_of_a_line(self): + venue = Venue(**{ + "POST /v4/mm/cancel_all_orders": {"data": {"success": True}}, + "POST /v4/mm/cancel_orders_by_market": lambda req: ( + {"data": {"success": True}} if json.loads(req.content)["strike_id"] == LIONS + else httpx.Response(404, json={"error": "data_not_found", "message": "none"})), + }) + + async def go(): + async with adapter(venue) as px: + return await px.cancel_all_orders(), await px.cancel_all_orders(market_id=MARKET_ID) + + assert run(go()) == (None, None) + bodies = [json.loads(r.content) for r in venue.sent("POST", "/v4/mm/cancel_orders_by_market")] + assert bodies == [{"event_id": 18925, "market_id": 219, "strike_id": LIONS}, + {"event_id": 18925, "market_id": 219, "strike_id": VIKINGS}] + + def test_nothing_to_cancel_is_zero(self): + venue = Venue(**{"POST /v4/mm/cancel_all_orders": httpx.Response( + 404, json={"error": "no_cancellable_orders", "message": "none"})}) + + async def go(): + async with adapter(venue) as px: + return await px.cancel_all_orders() + + assert run(go()) == 0 + + def test_open_orders_are_the_resting_ones_on_the_line(self): + rows = [ORDER_ROW, {**ORDER_ROW, "order_id": "partner_2", "matching_status": "fully_matched"}, + {**ORDER_ROW, "order_id": "partner_3", "strike_id": VIKINGS, "price": 124}] + venue = Venue(**{"GET /v4/mm/get_order_history": {"data": {"orders": rows, "next_cursor": None}}}) + + async def go(): + async with adapter(venue) as px: + return await px.fetch_open_orders(market_id=MARKET_ID) + + orders = run(go()) + assert [(o.id, o.side) for o in orders] == [(ORDER_ROW["order_id"], Side.BUY), ("partner_3", Side.SELL)] + params = dict(venue.sent("GET", "/v4/mm/get_order_history")[0].url.params) + assert params["status"] == "open" and params["event_id"] == "18925" and params["limit"] == "1000" + + def test_my_trades_find_their_line_through_the_strike(self): + venue = Venue(**{ + "GET /v4/mm/get_trades": {"data": {"trades": [TRADE_ROW], "next_cursor": "n2"}}, + "GET /v4/mm/get_strikes": {"data": [{"strike_id": VIKINGS, "event_id": 18925, "market_id": 219, + "type": "moneyline"}]}, + }) + + async def go(): + async with adapter(venue) as px: + return await px.fetch_my_trades(market_id=MARKET_ID) + + page = run(go()) + assert [(f.market_id, f.side, f.quantity) for f in page] == [(MARKET_ID, Side.SELL, Decimal("22.4"))] + assert page.next_cursor == "n2" + + def test_balance_and_positions(self): + rows = [ORDER_ROW, {**ORDER_ROW, "order_id": "partner_3", "strike_id": VIKINGS, "price": 124, + "filled_quantity": 2.0}, {**ORDER_ROW, "order_id": "partner_4", "filled_quantity": 0}] + venue = Venue(**{ + "GET /v4/mm/get_balance": BALANCE, + "GET /v4/mm/get_order_history": {"data": {"orders": rows}}, + }) + + async def go(): + async with adapter(venue) as px: + return await px.fetch_balance(), await px.fetch_positions() + + balance, [position] = run(go()) + assert (balance.available, balance.locked, balance.total) == (Decimal("1000.0"), Decimal("250.0"), Decimal("1250.0")) + assert balance.info["gec_balance"] == 500.0 + assert position.market_id == MARKET_ID and position.side == PositionSide.LONG + assert position.inventory_yes == Decimal("10.5") and position.inventory_no == Decimal("4.48") + assert position.contracts == Decimal("6.02") and position.margin == Decimal("7.5") + + +class TestWiring: + def test_the_affiliate_key_alone_does_not_trade(self): + with pytest.raises(CredentialsMissing, match="PROPHETX_ACCESS_KEY"): + ProphetXTrading(ProphetXCredentials(affiliate_key="k")) + + def test_the_client_builds_the_adapter(self): + from synpath import Client + + client = Client(credentials={"prophetx": CREDS}) + trading = client.trading("prophetx") + assert isinstance(trading, ProphetXTrading) and trading.url == "https://cash.api.prophetx.co/partner" + assert trading.catalog.session is trading.session + + def test_the_engine_streams_on_the_adapters_session(self): + from synpath.engine.feeds import default_streams + + trading = ProphetXTrading(CREDS, catalog=Catalog()) + streams = default_streams({"prophetx": trading}, {"prophetx": CREDS})["prophetx"] + assert streams.market.session is trading.session and streams.private.session is trading.session + assert streams.private.resolver == trading.side + + def test_doctor_says_what_each_key_does(self, tmp_path, monkeypatch, capsys): + from synpath.trading.__main__ import doctor + + for name in ("PROPHETX_ACCESS_KEY", "PROPHETX_SECRET_KEY", "PROPHETX_AFFILIATE_KEY", "PROPHETX_SANDBOX"): + monkeypatch.delenv(name, raising=False) + env = tmp_path / ".env" + env.write_text("PROPHETX_AFFILIATE_KEY=aff-key-123456\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + assert "affiliate key loaded; set PROPHETX_ACCESS_KEY / PROPHETX_SECRET_KEY to trade" in out + env.write_text("PROPHETX_ACCESS_KEY=acc-key-123456\nPROPHETX_SECRET_KEY=sec-key-123456\nPROPHETX_SANDBOX=1\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + assert "configured (sandbox)" in out and "Trading API key pair loaded" in out + assert "key-123456" not in out diff --git a/tests/test_prophetx_ws.py b/tests/test_prophetx_ws.py new file mode 100644 index 0000000..6f1c60c --- /dev/null +++ b/tests/test_prophetx_ws.py @@ -0,0 +1,242 @@ +"""ProphetX's streams: the Pusher wire protocol and ProphetX's registration, +spoken against a fake socket. Frames are the examples in ProphetX's WebSocket +guides (docs.prophetx.co): the connection config, `connection_established`, +the sanitized registration answer, a `trade_execution` and a `health_check`; +`market_selections` carries the documented payload fields with the documented +markets' values.""" +from __future__ import annotations + +import asyncio +import base64 +import json +from decimal import Decimal + +import pytest + +from synpath import AuthenticationError, ProphetXMarketStream, ProphetXUserStream +from synpath.prophetx import lines_of, markets_of, probability +from synpath.trading.types import OrderStatus, Side +from synpath.ws.base import BookEvent, FillEvent, OrderEvent, QuoteEvent, StreamStatusEvent, TradeEvent +from synpath.ws.prophetx import decode, payload_of, ws_url + +from conftest import load + +WS = load("prophetx_ws.json") +V3 = load("prophetx_markets_v3.json") +LIONS = "90de769f4f4977a04446ed203e957351" +VIKINGS = "7d75b0c55f5578219f534413d0bd872b" +MARKET = f"prophetx:18925-{LIONS}" + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +class FakeSocket: + def __init__(self): + self.sent: list = [] + + async def send(self, text): + self.sent.append(json.loads(text)) + + +class Catalog: + def __init__(self, lines=None): + self._lines = lines + + def lines(self, event_id): + if self._lines is not None: + return self._lines + return [{**ln, "event_id": event_id} for m in markets_of(V3) for ln in lines_of(m)] + + def fetch_sport_events(self, *, event_ids=None, tournament_id=None): + return [{"event_id": int(e), "tournament_id": 31} for e in event_ids or []] + + +def registration(event_id="18925"): + """The documented registration answer, its event channel on our event.""" + return json.loads(json.dumps(WS["register"]["data"]).replace("10078829", event_id)) + + +def frame(event, channel, payload, change_type="market_selections"): + data = {"timestamp": 1717689526365275600, "change_type": change_type, "op": "u", + "payload": base64.b64encode(json.dumps(payload).encode()).decode()} + return json.dumps({"event": event, "channel": channel, "data": json.dumps(data)}) + + +def selections(sequence, *, lions_price=-470, lions_value=470): + """A `market_selections` payload for the documented moneyline (market 219).""" + moneyline = markets_of(V3)[0] + one, two = moneyline["selections"] + return {"sport_event_id": 18925, "market_id": 219, "info": { + "id": 219, "name": "Moneyline", "type": "moneyline", "strike": 0, "sequence_number": sequence, + "selections": [[{**one, "price": lions_price, "value": lions_value}], [two]]}} + + +async def settle(): + for _ in range(10): + await asyncio.sleep(0) + + +async def handshake(stream): + stream._ws = FakeSocket() + stream._dispatch(json.dumps(WS["connection_established"])) + await settle() + return stream + + +EVENT_CHANNEL = "private-broadcast-service=6-device_type=5-event=18925" +PRIVATE_CHANNEL = "private-service=6-device_type=5-user=" +BROADCAST_CHANNEL = "private-broadcast-service=6-device_type=5" + + +class TestProtocol: + def test_the_url_is_the_ws_host_or_the_cluster(self): + assert ws_url({"key": "k", "ws_host": "talaria.example"}) == "wss://talaria.example/app/k?protocol=7&client=synpath&version=1" + assert ws_url({"key": "k", "cluster": "us2"}).startswith("wss://ws-us2.pusher.com/app/k?protocol=7") + with pytest.raises(AuthenticationError): + ws_url({"cluster": "us2"}) + + def test_data_is_json_in_a_string_and_the_payload_base64(self): + assert decode(WS["connection_established"]["data"]) == {"socket_id": "123.456"} + assert payload_of(WS["health_check"]["data"]) == {} + + def test_a_stream_needs_the_key_pair(self): + with pytest.raises(AuthenticationError, match="PROPHETX_ACCESS_KEY"): + ProphetXMarketStream() + + +class TestMarketStream: + def stream(self, registered): + async def register(body): + registered.append(body) + return registration() + + return ProphetXMarketStream(config=WS["connection_config"], register=register, catalog=Catalog()) + + def test_the_handshake_registers_signs_in_and_then_subscribes(self): + registered: list = [] + + async def go(): + stream = self.stream(registered) + await stream.watch_order_book([MARKET]) + await handshake(stream) + before = list(stream._ws.sent) + stream._dispatch(json.dumps({"event": "pusher:signin_success", "data": {}})) + await settle() + return before, stream._ws.sent + + before, sent = asyncio.run(go()) + assert registered == [{"socket_id": "123.456", "service": "pusher", + "subscriptions": [{"type": "event", "ids": ["18925"]}]}] + assert [f["event"] for f in before] == ["pusher:signin"] + assert before[0]["data"]["auth"] == ":" + [subscribe] = [f for f in sent if f["event"] == "pusher:subscribe"] + assert subscribe["data"] == {"auth": ":", "channel": EVENT_CHANNEL} + + def test_a_joined_event_seeds_its_books_and_updates_replace_them(self): + registered: list = [] + + async def go(): + stream = self.stream(registered) + await stream.watch_order_book([MARKET]) + await stream.watch_ticker([MARKET]) + await handshake(stream) + stream._dispatch(json.dumps({"event": "pusher:signin_success", "data": {}})) + await settle() + stream._dispatch(json.dumps({"event": "pusher_internal:subscription_succeeded", "data": "{}", + "channel": EVENT_CHANNEL})) + await settle() + seeded = drain(stream) + stream._dispatch(frame("v2.market_selections", EVENT_CHANNEL, selections(5, lions_price=-450, lions_value=90))) + stream._dispatch(frame("market_selections", EVENT_CHANNEL, selections(6))) + stream._dispatch(frame("v2.market_selections", EVENT_CHANNEL, selections(4))) + return seeded, drain(stream) + + seeded, updates = asyncio.run(go()) + books = [e for e in seeded if isinstance(e, BookEvent)] + assert books and books[0].market_id == MARKET and books[0].best_ask == Decimal(str(probability(-470))) + [book, quote] = updates + assert isinstance(book, BookEvent) and book.kind == "snapshot" and book.sequence == 5 + assert book.best_ask == Decimal(str(probability(-450))) + assert book.asks[0].size == Decimal(str(round(90 / probability(-450), 6))) + assert book.best_bid == Decimal(str(round(1 - probability(-136), 8))) + assert isinstance(quote, QuoteEvent) and quote.ask == book.best_ask + + def test_trades_come_from_the_tournament_tape(self): + registered: list = [] + tape = {**WS["trade_execution"], "info": {**WS["trade_execution"]["info"], "strike_id": VIKINGS, + "sport_event_id": 18925}} + + async def go(): + stream = self.stream(registered) + await stream.watch_trades([MARKET]) + await handshake(stream) + stream._dispatch(frame("v2.tournament_31", BROADCAST_CHANNEL, tape, change_type="trade_execution")) + stream._dispatch(frame("v2.tournament_31", BROADCAST_CHANNEL, WS["trade_execution"], + change_type="trade_execution")) + return drain(stream) + + events = asyncio.run(go()) + assert registered[0]["subscriptions"] == [{"type": "event", "ids": ["18925"]}, + {"type": "tournament", "ids": ["31"]}] + [trade] = [e for e in events if isinstance(e, TradeEvent)] + # 45.30 staked on the Vikings at +124 returns 45.30 * 224/100 contracts; a NO buy is a YES sell. + assert trade.market_id == MARKET and trade.taker_side == Side.SELL + assert trade.price == 1 - Decimal(100) / Decimal(224) and trade.quantity == Decimal("101.472000") + + def test_ping_is_answered_and_a_disconnect_invalidates_books(self): + registered: list = [] + + async def go(): + stream = self.stream(registered) + await stream.watch_order_book([MARKET]) + stream._ws = FakeSocket() + stream._dispatch(json.dumps({"event": "pusher:ping", "data": {}})) + await settle() + stream.books["18925-" + LIONS].replace([], [(Decimal("0.5"), Decimal("1"))]) + stream.on_disconnect() + return stream + + stream = asyncio.run(go()) + assert stream._ws.sent == [{"event": "pusher:pong", "data": {}}] + assert not stream.books["18925-" + LIONS].ready and stream.socket_id is None and not stream.live + + +class TestUserStream: + def test_orders_and_fills_arrive_on_the_private_channel(self): + registered: list = [] + + async def register(body): + registered.append(body) + return registration() + + async def resolver(strike_id, *, event_id=None): + return (MARKET, strike_id == LIONS) + + row = {"id": "partner_1", "external_id": "c-1", "strike_id": VIKINGS, "sport_event_id": 18925, "market_id": 219, + "price": 124, "quantity": 10.0, "filled_quantity": 4.0, "fill_price": 124, "total_filled_quantity": 4.0, + "open_quantity": 6.0, "matching_status": "partially_matched", "status": "open", "update_type": "matching", + "matching_id": 991, "aggressive": False, "sequence_number": 3} + + async def go(): + stream = ProphetXUserStream(config=WS["connection_config"], register=register, resolver=resolver) + await stream.watch_orders() + await stream.watch_my_trades() + await handshake(stream) + stream._dispatch(json.dumps({"event": "pusher:signin_success", "data": {}})) + await settle() + stream._dispatch(frame("orders", PRIVATE_CHANNEL, {"info": row}, change_type="orders")) + stream._dispatch(frame("health_check", PRIVATE_CHANNEL, {}, change_type="private_system_signal")) + await settle() + return stream._ws.sent, drain(stream) + + sent, events = asyncio.run(go()) + assert [f["data"]["channel"] for f in sent if f["event"] == "pusher:subscribe"] == [PRIVATE_CHANNEL] + order, fill = [e for e in events if not isinstance(e, StreamStatusEvent)] + assert isinstance(order, OrderEvent) and order.order.status == OrderStatus.OPEN and order.order.side == Side.SELL + assert order.order.market_id == MARKET and order.native == "matching:open" + assert isinstance(fill, FillEvent) and fill.fill.id == "991" and fill.fill.quantity == Decimal("8.96") From ea593d7f7bc5eabd1ab6641a888e569543c5ed15 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 18:00:00 +0100 Subject: [PATCH 06/19] feat: add azuro Co-Authored-By: Claude Opus 5.5 --- .env.example | 14 + README.md | 5 +- docs/api.md | 27 +- docs/capabilities.md | 106 +- docs/streaming.md | 17 + docs/trading.md | 54 +- synpath/__init__.py | 10 +- synpath/azuro.py | 1086 +++ synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 7 + synpath/ids.py | 5 +- synpath/server/serve.py | 2 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 6 +- synpath/trading/azuro.py | 722 ++ synpath/trading/azuro_signing.py | 316 + synpath/trading/credentials.py | 59 +- synpath/trading/init.py | 14 + synpath/trading/instruments.py | 7 + synpath/types.py | 7 + synpath/ws/__init__.py | 5 +- synpath/ws/azuro.py | 263 + tests/samples/azuro_bets.json | 54 + tests/samples/azuro_condition_batch.json | 104 + tests/samples/azuro_conditions.json | 4609 ++++++++++++ tests/samples/azuro_conditions_resolved.json | 6636 ++++++++++++++++++ tests/samples/azuro_games.json | 408 ++ tests/samples/azuro_games_by_ids.json | 84 + tests/samples/azuro_navigation.json | 4050 +++++++++++ tests/samples/azuro_search.json | 44 + tests/samples/azuro_signing_vectors.json | 253 + tests/test_azuro.py | 370 + tests/test_azuro_live.py | 54 + tests/test_azuro_trading.py | 499 ++ tests/test_azuro_ws.py | 203 + tests/test_init.py | 35 +- 37 files changed, 20084 insertions(+), 63 deletions(-) create mode 100644 synpath/azuro.py create mode 100644 synpath/trading/azuro.py create mode 100644 synpath/trading/azuro_signing.py create mode 100644 synpath/ws/azuro.py create mode 100644 tests/samples/azuro_bets.json create mode 100644 tests/samples/azuro_condition_batch.json create mode 100644 tests/samples/azuro_conditions.json create mode 100644 tests/samples/azuro_conditions_resolved.json create mode 100644 tests/samples/azuro_games.json create mode 100644 tests/samples/azuro_games_by_ids.json create mode 100644 tests/samples/azuro_navigation.json create mode 100644 tests/samples/azuro_search.json create mode 100644 tests/samples/azuro_signing_vectors.json create mode 100644 tests/test_azuro.py create mode 100644 tests/test_azuro_live.py create mode 100644 tests/test_azuro_trading.py create mode 100644 tests/test_azuro_ws.py diff --git a/.env.example b/.env.example index 0934e99..3257fee 100644 --- a/.env.example +++ b/.env.example @@ -114,3 +114,17 @@ BETFAIR_JURISDICTION= # prohibited. SMARKETS_USERNAME= SMARKETS_PASSWORD= + +# Azuro (onchain sports betting pool): market data needs nothing. To bet, the +# private key of the wallet that signs each bet and holds the bet token (USDT on +# Polygon, WETH on Base); the wallet must also approve Azuro's relayer to spend +# the token once (AzuroTrading.approve(), an on-chain transaction). AZURO_CHAIN +# picks the pool: polygon (default), base, or the testnets polygon_amoy and +# base_sepolia. AZURO_AFFILIATE (optional) is the affiliate address each bet +# names, the wallet's own when unset. AZURO_RPC_URL (optional) is the node the +# balance and allowance are read from and the approval is sent through. Azuro's +# bet API and streams are refused from some regions. +AZURO_PRIVATE_KEY= +AZURO_CHAIN= +AZURO_AFFILIATE= +AZURO_RPC_URL= diff --git a/README.md b/README.md index 649c0d2..428f029 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,14 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | +| | [Azuro](https://azuro.org) | `azuro` | ✓ sports, no key | ✓ bets with a worst price | WebSocket (no key) | no book: the pool's quoted odds, one level a side sized by its limits | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). +Azuro is an onchain sports betting protocol: a shared liquidity pool takes every bet at the odds it quotes, so there is no order book and nothing rests. A two-outcome condition ("Total Goals Over/Under 2.5") is one yes/no market, `azuro:`, with the lower outcome id as YES; a condition with three or more outcomes (1X2, correct score) is one market per outcome, `azuro:-`, where only YES can be bought. The game is the event. Prices are `1 / odds`. Ids are the same on every chain; `Azuro(chain=...)` picks the pool read (Polygon USDT by default, Base WETH). Bets are EIP-712 messages signed by your wallet and placed on chain by Azuro's relayer. Azuro's bet API and streams are refused from some regions. See [capabilities](docs/capabilities.md#azuro). + ## Why Synpath - **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. @@ -135,7 +138,7 @@ fee.estimate(price=0.50, contracts=100) # 1.75 **Same code, every venue** ```python -for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets'] (Betfair and predict.fun need keys) +for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets', 'azuro'] (Betfair and predict.fun need keys) with synpath.exchange(venue_id) as venue: page = venue.fetch_markets(limit=5) diff --git a/docs/api.md b/docs/api.md index dfbf90e..271223a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,7 +77,7 @@ and spot exchanges do not. | Method | Returns | Notes | |---|---|---| | `fetch_markets(query, limit, cursor, status, sort)` | `Page[Market]` | `limit` up to 100 | -| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read), order preserved, closed and settled markets included | +| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read; Azuro reads each game's conditions once, ten games a request), order preserved, closed and settled markets included | | `fetch_events(query, limit, cursor, status)` | `Page[Event]` | markets nested | | `fetch_market(market_id)` | `Market` | | | `fetch_order_book(market_id, side, depth)` | `OrderBook` | `side="yes"` (default) or `"no"` | @@ -122,6 +122,24 @@ book.info["bids"], book.info["offers"] # Smarkets' own [price bps, quantity] smk.fetch_trades(contracts[0].id) # the short recent tape ``` +Azuro needs no key. It adds `fetch_sports()` (the feed's sports, countries +and leagues with their open game counts), `fetch_limits(market_id, side)` +(the pool's minimum and maximum bet and maximum payout on one selection) and +`fetch_condition_states(condition_ids)` (fresh odds and states, without the +games), and takes `sport` and `league` (the feed's slugs, `"football"`, +`"premier-league"`) on `fetch_markets` and `fetch_events`. `chain=` picks the +pool read: `polygon` (the default), `base`, or a testnet. See +[Azuro](capabilities.md#azuro). + +```python +azuro = synpath.Azuro() # the Polygon pool; Azuro(chain="base") for Base +markets = azuro.fetch_markets(sport="football", limit=20) +market = markets[0] +market.yes.quote.ask, market.info["odds"] # 1 / odds, and the venue's odds +azuro.fetch_limits(market.id) # {"min_bet", "max_bet", "max_payout", ...} +azuro.fetch_trades(market.id) # single bets, from the bets subgraph +``` + Polymarket also has `fetch_order_books_by_token(token_ids, depth)`, by CLOB token id. @@ -204,7 +222,7 @@ result.matched # MarketLink | None Matching covers every venue this library speaks: `kalshi`, `polymarket`, `polymarket_us`, `opinion`, `hyperliquid`, `predict_fun`, `limitless` and -`sx_bet` (not Betfair or Smarkets yet). `venue=` asks one of them in particular; without it the answer is +`sx_bet` (not Betfair, Smarkets or Azuro yet). `venue=` asks one of them in particular; without it the answer is the first venue listing the same question, in the order Polymarket, Kalshi, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Polymarket US. @@ -256,13 +274,14 @@ A venue that cannot filter by one of them raises `NotSupported`. Polymarket does for `settled`, since its catalog does not publish resolution status, and Betfair for everything but `open`, since a closed market leaves its catalogue (read it by id instead). Smarkets filters on its events' states: `closed` is an -ended or suspended event, `settled` a settled or cancelled one. +ended or suspended event, `settled` a settled or cancelled one. Azuro lists +`open` games only and raises for the rest; a settled market is read by id. Anything outside the four raises `BadRequest` rather than being handed to the venue to interpret for itself. ### Identifiers -One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). +One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. On Azuro a two-outcome condition is one market, `azuro:`, a condition with three or more outcomes one market per outcome, `azuro:-`, and the game is the event, `azuro:`; the ids are the same on every chain. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). ### Paging diff --git a/docs/capabilities.md b/docs/capabilities.md index b521b2a..3b7f913 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -6,21 +6,21 @@ Read `has` before you call. A capability a venue lacks raises `NotSupported` rather than returning an empty list, so "cannot" is never mistaken for "there is nothing". -| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | -|---|---|---|---|---|---|---|---|---|---|---| -| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | -| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | -| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | -| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | -| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | -| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | -| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | -| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | -| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | -| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | -| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | -| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | -| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | `azuro` | +|---|---|---|---|---|---|---|---|---|---|---|---| +| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | yes, a two-outcome condition is one market (lower outcome id YES), a larger one a market per outcome; live games first, then upcoming; quoted from the pool's odds; filter by sport or league | +| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | yes, one per game, every condition's markets | +| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | yes, open or settled | +| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | partial: no book; the pool's quote as one level a side, sized by its limits (`/bet/calculation`); NO mirrored | +| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | yes, up to three requests per market | +| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | yes, every single bet, from the bets subgraph | +| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | partial: built from the bets, with volume; at most 2,000 bets back | +| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | +| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | yes, nothing on top of the odds (`pool_margin`); the relayer's flat fee per bet is on `fetch_fee_estimate` | +| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | yes, the feed's game search | +| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | yes, each game's conditions once, ten games a request | +| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | no: the feed orders games, not markets | +| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | no | Polymarket US is the CFTC-regulated exchange, not the on-chain CLOB the `polymarket` adapter reads; the two share a brand and nothing else. It has @@ -182,6 +182,53 @@ betting on your own account; odds scraping is prohibited. Which countries Smarkets refuses API use from is not published with its API documentation and is unverified here. +### Azuro + +Azuro is an onchain sports betting protocol. Every bet is taken by a shared +liquidity pool at the odds the protocol quotes, so there is no order book and +nothing rests. The feed (`api.onchainfeed.org`) lists sports, games and their +*conditions* ("Match Winner", "Total Goals Over/Under 2.5", "Correct Score"), +each with two or more outcomes, exactly one of which wins, or the condition is +cancelled and every bet returned. + +A two-outcome condition is one market, `azuro:`: the outcome with +the lower `outcomeId` is YES and the other NO, under the venue's names for +them, so betting the second outcome is selling YES. A condition with three or +more outcomes (1X2, double chance, correct score) is one market per outcome, +`azuro:-`, answering "does this outcome win?"; only its +YES can be bought, since the pool sells no "not this outcome". The game is the +event, `azuro:`, for every condition on it. + +Prices are the pool's decimal odds `O` as `1 / O`, rounded to 8 decimal places +(odds of 1 or less, and outcomes not taking bets, are no price). A contract +pays one token. On a two-outcome market the YES ask is `1 / O` of the YES +outcome and the YES bid one minus `1 / O` of the other, so the pool's margin is +the spread; one outcome of a larger condition has an ask only. The venue's +odds stay on `info["odds"]` and on each outcome's `info`. Depth is the pool's +limit on a selection: `fetch_limits(market_id, side=)` reads `/bet/calculation` +(`min_bet` and `max_bet` in the bet token, `max_payout` the most contracts it +sells), and `fetch_order_book` is the quote as one level a side sized by it. + +The protocol runs a pool per chain: Polygon (USDT, six decimals) and Base +(WETH, eighteen), with the Polygon Amoy and Base Sepolia testnets; Gnosis and +Chiliz are discontinued and every condition there reads stopped. A game and its +conditions have the same ids on every chain, so ids carry no chain; +`Azuro(chain="base")` reads the Base pool's states and odds, and its contracts +pay out in WETH. Listings are open games only, live first, then upcoming by +start time, and take `sport` and `league` (the feed's slugs). `fetch_sports()` +returns the feed's sports, countries and leagues with their open game counts. + +The tape is every single bet on a condition, from Azuro's bets subgraph +(`thegraph.onchainfeed.org`), in the YES price with the bettor's side; combo +bets are left out. There is no fee on top of the odds (`pool_margin`); a bet +placed through the relayer pays it a flat fee per bet in the bet token, which +`AzuroTrading.fetch_fee_estimate` reads. + +The feed's market routes and the subgraph answer from anywhere; its bet, +cash-out and stream routes (`/bet/*`, `/cashout/*`, the WebSocket) answer some +regions with a 503, so `fetch_limits`, `fetch_order_book` and the stream need a +region Azuro serves. + Matching spans venues, so no adapter claims it. `synpath.match_market(id)` and `synpath.match_event(id)` are answered by Synpath's hosted matching service at `api.synpath.dev`, with a Synpath API key (the one `synpath keys create` saved, `SYNPATH_API_KEY`, or `api_key=`). See @@ -209,20 +256,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | -|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | -| `rfq` | yes | no | no | no | no | no | no | no | no | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `azuro` | +|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; open orders are bets not yet on chain | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, the wallet's bets, open or closed | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, accepted bets, one fill each | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | yes, settled bets: won, lost or cancelled | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the relayer's flat fee per bet | +| `rfq` | yes | no | no | no | no | no | no | no | no | no | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -258,6 +305,7 @@ Every stream answers the `watch_*` keys the same way | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | +| `AzuroMarketStream` | partial: the pool's quote as one level a side, sized by its limits read over REST | yes, every change of odds | no: read the bets with `fetch_trades` | yes, per condition | no | no | no | no | The exchange API's other gRPC streams carry one thing each and say so: drop copy and trade capture carry fills, position and position-change carry diff --git a/docs/streaming.md b/docs/streaming.md index 7a577f6..20c9a7e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -42,6 +42,7 @@ asyncio.run(main()) | `SxBetMarketStream` | `realtime.sx.bet` (or the testnet) | SX Bet API key (every channel needs one) | whole books, top of book, the tape of taker bets, market changes and settlements | | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | +| `AzuroMarketStream` | `streams.onchainfeed.org` (`dev-streams` for the testnets) | none | each condition's odds as quotes, one-level books sized by the pool's limits, condition status changes | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | Each stream's `has` answers the `watch_*` capability keys (`watch_order_book`, @@ -69,6 +70,7 @@ variable `synpath serve` reads it from: | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | | Betfair | the application key and a login (every Betfair call needs one) | `BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD` | | Smarkets | nothing; an optional login undelays the quotes | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | +| Azuro | nothing | `AZURO_CHAIN` picks the pool when trading | `synpath serve` builds a market stream for Opinion, predict.fun and SX Bet from the API key alone (`load_market_data`), so a parent on any venue can @@ -189,6 +191,21 @@ jittered exponential backoff between attempts. `conflate_ms` asks. The stream logs a warning once when it sees that, and waits a whole conflation window before treating silence as a dead socket. No public trades, so no `watch_trades`; no account stream yet. +- *Azuro* speaks plain JSON on `wss://streams.onchainfeed.org/v1/streams/feed`: + `SubscribeConditions` (and `UnsubscribeConditions`) with the condition ids + and the chain's environment (`PolygonUSDT`, `BaseWETH`), and a + `ConditionUpdated` message whenever a condition's odds or state change, + carrying every outcome's `currentOdds`. Each update is read as the REST + quotes are: buying YES at `1 / odds` of the YES outcome and, on a + two-outcome market, selling at one minus the price of the other. An update + that names only some outcomes keeps the others' last odds. A pool has no + book, so `watch_order_book` keeps the quote as one level a side sized by the + pool's limits, read over REST (`Azuro.fetch_order_book`) when the market is + watched and after every reconnect; updates move the prices and keep those + sizes. A condition stopping, resuming, resolving or being cancelled is a + `MarketStatusEvent` (`paused`, `open`, `settled` with `yes`, `no` or + `void`). No trades channel: bets are on the subgraph (`Azuro.fetch_trades`). + The feed's socket answers some regions with a 503. - *Smarkets* has no public stream: its socket feed (the protobuf stream on `api.smarkets.com:3701` that `smk_python_sdk` speaks) is for approved API users and market makers. `SmarketsMarketStream` polls the REST quotes diff --git a/docs/trading.md b/docs/trading.md index 27a5edb..3863b3c 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -24,7 +24,7 @@ pip install synpath ``` Order entry ships in the base package. The install carries the venues' signing -stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, +stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Azuro, Ed25519 and private-key JWT for Polymarket US. Nothing further is needed to place an order. Polymarket and Opinion orders are hashed and signed by the Rust core @@ -71,6 +71,7 @@ private key. `synpath init` tells them apart. | predict.fun | mainnet: the API key (`pred_sk_...`). Test network: nothing | the API key and a private key: a plain wallet's, or a Predict account's owner key with its deposit address | the key at developers.predict.fun; a web-app account's owner key under Account -> Settings -> Privy wallet | | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | +| Azuro | nothing | the private key of the wallet that signs each bet and holds the bet token, which must allow Azuro's relayer to spend it (`approve()`, once) | exported from the wallet | For Opinion, predict.fun and SX Bet the API key alone is a complete market-data setup: `synpath init` asks for it first and takes an empty @@ -95,6 +96,7 @@ The variables each venue reads: | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | +| Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance and allowance are read from and the approval is sent through; a public one when unset) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, optional `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` and `BETFAIR_JURISDICTION`) @@ -573,3 +575,53 @@ each market on the YES leg. The balance is the proxy wallet's USDC: what is free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. + +## Azuro + +`AzuroTrading`, built to `@azuro-org/toolkit` and the Azuro backend API. Bet +and cash-out digests and signatures are checked against the ones the toolkit +and viem produce. + +The one credential is the wallet. Every bet is the EIP-712 message +`ClientBetData` (the consent text, the affiliate, the pool's core contract, a +five-minute expiry, the chain id, the relayer's fee, and one `SubBet`: the +condition, the outcome, the minimum odds, the stake and a nonce) under the +domain `Live Betting` `1.0.0` with the core contract as verifying contract, +signed by the wallet and sent to `/bet/orders/ordinar`. Azuro's relayer puts +it on chain and is paid a flat fee per bet (`/bet/gas-info`) from the wallet's +bet token, so before the first bet the wallet allows the relayer to spend the +token: `fetch_allowance()` reads the allowance, `approve()` sends the +approval, an on-chain transaction whose gas the wallet pays in the chain's +coin. `fetch_balance()` is the wallet's bet token, read on chain. + +A bet is a market order with a worst price. The pool takes it whole at the +odds it quotes when the bet reaches the chain, or refuses it, so an order is +`market`, or a `limit` that is `ioc` or `fok`; anything that would rest, and +`post_only` and `reduce_only`, are refused. `buy` backs the market's YES +outcome with minimum odds `1 / price`, rounded up to the protocol's twelve +decimals; `sell` backs the other outcome of a two-outcome market with minimum +odds `1 / (1 - price)`. One outcome of a larger condition has no NO to sell. +`n` contracts stake `n` times the price paid, rounded down to the token's +decimals, so at the quoted odds or better the bet returns at least `n`. + +Placing is asynchronous: the API answers with the bet order's id +(`_`) in state `Created`, and `create_order` returns it +`pending`. The relayer sends it on (`Placed`, `Sent`) and it becomes +`Accepted`, which is `closed` and filled here, or `Rejected`. +`wait_for_order(id)` reads it until then. A signed bet cannot be cancelled; +an accepted one can be closed early where the venue offers a price: +`fetch_cashout_quote(bet_id)` reads the offer by the bet's on-chain id +(`Order.info["betId"]`), `cash_out(bet_id)` signs it (`CashOutOrder` under +the domain `Cash Out` `1.0.0`, the offer's odds as the minimum) and sends it, +and `fetch_cashout(id)` follows it. + +`fetch_orders`, `fetch_open_orders` (bets not yet on chain), `fetch_my_trades` +(one fill per accepted bet) and `fetch_settlements` (won, lost, or cancelled +with the stake returned) read the wallet's bet orders. Positions net each +market's accepted, unsettled single bets on the YES leg. Combo bets are read +on their first leg's market and marked `info["combo"]`; this library places +single bets only. A winning bet's payout is claimed on chain from the pool, +which this library does not do. + +Gnosis and Chiliz are discontinued and take no bets. Azuro's bet API answers +some regions with a 503; check where Azuro may be used before betting. diff --git a/synpath/__init__.py b/synpath/__init__.py index b43731a..ca12f66 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -49,6 +49,7 @@ from .sx_bet import SxBet from .betfair import Betfair from .smarkets import Smarkets +from .azuro import Azuro from .matching import match_event, match_market from .history import fetch_order_book_at, fetch_order_book_range, fetch_trades_range from .bucket import Bucket, BucketMember, BucketOrderReport, BucketPosition @@ -95,6 +96,7 @@ "sx_bet": SxBet, "betfair": Betfair, "smarkets": Smarkets, + "azuro": Azuro, } """Every venue this library speaks, by id.""" @@ -115,7 +117,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, AzuroTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -145,7 +147,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "AzuroMarketStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -167,7 +169,7 @@ def __getattr__(name: str): __all__ = [ - "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Exchange", "exchange", "exchanges", + "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Azuro", "Exchange", "exchange", "exchanges", "Capability", "HttpClient", "RateLimiter", "Market", "Event", "Outcome", "Quote", "MarketStats", "OrderBook", "OrderLevel", "Trade", "Candle", "FeeSchedule", "Series", "Page", @@ -184,7 +186,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "AzuroTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/azuro.py b/synpath/azuro.py new file mode 100644 index 0000000..c13caa3 --- /dev/null +++ b/synpath/azuro.py @@ -0,0 +1,1086 @@ +"""Azuro: an onchain sports betting protocol, read from its feed API and its bets subgraph. + +Market data needs no key. The feed (`api.onchainfeed.org`, the `market-manager` +routes) lists sports, games, their conditions and every outcome's current +odds; the bets subgraph (`thegraph.onchainfeed.org`) has every bet placed, +which is the public tape. The feed's bet, cash-out and stream routes refuse +requests from some regions with a 503. + +Five things about this venue that shape the adapter: + +**A pool, not a book.** Every bet is taken by a shared liquidity pool at the +odds the protocol quotes for its outcome; nothing rests and nobody else's +order is matched. The quoted odds are this library's quote: backing an +outcome at decimal odds `O` is buying its YES at `1 / O` (one contract pays 1 +token, and a stake `S` returns `S * O`). Prices here are exactly `1 / O`, +rounded to 8 decimal places; the venue's odds stay on `info`. Odds of 1 or +less, and outcomes not taking bets, are no price at all (`None`). + +**A condition is the question, its outcomes the answers.** A game ("Arsenal - +Chelsea") carries dozens of conditions ("Match Winner", "Total Goals 2.5", +"Correct Score"), each with two or more outcomes, exactly one of which wins +(or the condition is cancelled and every bet is returned). Following SX Bet, +a two-outcome condition is one Synpath market, `azuro:`: the +outcome with the lower `outcomeId` is YES and the other NO, each under the +venue's name for it, so betting the second outcome is selling YES. Following +Betfair, a condition with three or more outcomes is one Synpath market per +outcome, `azuro:-`, each answering "does this outcome +win?"; only its YES can be bought, since the pool sells no "not this +outcome". In every case the game is the event, `azuro:`. + +**One id on every chain.** The protocol runs a pool per chain (Polygon USDT, +Base WETH, and the discontinued Gnosis and Chiliz pools), but the feed names +a game and its conditions with the same ids on all of them, so ids carry no +chain. The chain the adapter is built for (`chain="polygon"`, the default) +decides which pool's state and odds are read, and the currency a contract +pays out in: USDT on Polygon, WETH on Base. + +**Depth is the pool's limit.** A pool takes a bet up to the largest payout it +will risk on that outcome, which the feed computes per selection +(`/bet/calculation`). `fetch_order_book` is the quote as one level a side, +sized by that limit in contracts; `fetch_limits` returns it raw. + +**No fee on top.** The pool's margin is inside the odds it quotes. A bet +placed through the relayer also pays the relayer a flat fee in the bet token +for the transaction (`AzuroTrading.fetch_fee_estimate`). +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any, Iterator + +import httpx + +from . import ids +from .base import ( + MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort, check_status, page_limit, + pick_bars, timeframe_seconds, +) +from .errors import BadRequest, ExchangeError, MarketNotFound, NotSupported +from .types import ( + BookSide, Candle, Event, FeeSchedule, Market, MarketStats, OrderBook, OrderLevel, Outcome, Page, Quote, Trade, + iso, +) + +VENUE = "azuro" + +API_URL = "https://api.onchainfeed.org/api/v1/public" +DEV_API_URL = "https://dev-api.onchainfeed.org/api/v1/public" +SOCKET_URL = "wss://streams.onchainfeed.org/v1/streams" +DEV_SOCKET_URL = "wss://dev-streams.onchainfeed.org/v1/streams" +SUBGRAPH_URL = "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-{name}-v3" + + +@dataclass(frozen=True) +class Chain: + """One Azuro deployment: a chain, its pool's contracts and its bet token, + as `@azuro-org/toolkit` 7.2.0 lists them.""" + + name: str + chain_id: int + environment: str + """The feed's name for this deployment, sent with every request.""" + subgraph: str + """The bets subgraph's name for this chain.""" + core: str + relayer: str + lp: str + cashout: str + token: str + symbol: str + decimals: int + rpc_url: str + """A public RPC endpoint, used when the caller gives none.""" + testnet: bool = False + discontinued: bool = False + + @property + def api_url(self) -> str: + return DEV_API_URL if self.testnet else API_URL + + @property + def socket_url(self) -> str: + return DEV_SOCKET_URL if self.testnet else SOCKET_URL + + @property + def subgraph_url(self) -> str: + return SUBGRAPH_URL.format(name=self.subgraph) + + +CHAINS: dict[str, Chain] = { + "polygon": Chain( + "polygon", 137, "PolygonUSDT", "polygon", + core="0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", relayer="0x8dA05c0021e6b35865FDC959c54dCeF3A4AbBa9d", + lp="0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", cashout="0x4a2BB4211cCF9b9eA6eF01D0a61448154ED19095", + token="0xc2132d05d31c914a87c6611c10748aeb04b58e8f", symbol="USDT", decimals=6, + rpc_url="https://polygon-rpc.com", + ), + "base": Chain( + "base", 8453, "BaseWETH", "base", + core="0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d", relayer="0xD2D508d66dB4fCE4B384e4C3EA2fa53BA43e73b5", + lp="0x1eD7368bc515E928A4007cEa61FB8a6F8863Af87", cashout="0x6EDff24761F4473611B45BDAe4a779ff31af14Be", + token="0x4200000000000000000000000000000000000006", symbol="WETH", decimals=18, + rpc_url="https://mainnet.base.org", + ), + "gnosis": Chain( + "gnosis", 100, "GnosisXDAI", "gnosis", + core="0x0e00E6ffcb5E207B800eec9e02999D1Fd9e448f2", relayer="0x40eb85018fA18dDd10a433C8c40ca8D6744d8B32", + lp="0xeb7cDA87D00d677A6Dc73EB569723b0fA51D97E7", cashout="0x25de227af58188e656524d1C32cCf7C078cdC891", + token="0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", symbol="WXDAI", decimals=18, + rpc_url="https://rpc.gnosischain.com", discontinued=True, + ), + "chiliz": Chain( + "chiliz", 88888, "ChilizWCHZ", "chiliz", + core="0xa5061617Ee6565CF48d7f0FEF06910b9fb9dE2b0", relayer="0x81F72B93ABaf061535aaF5D831F05e0CC4084b32", + lp="0xEf6b12580301b04CD2551182C88623524B6e47b8", cashout="0x3995eebB51793Ee353162E7400DB455B17dE3692", + token="0x677F7e16C7Dd57be1D4C8aD1244883214953DC47", symbol="WCHZ", decimals=18, + rpc_url="https://rpc.chiliz.com", discontinued=True, + ), + "polygon_amoy": Chain( + "polygon_amoy", 80002, "PolygonAmoyUSDT", "polygon-amoy-dev", + core="0xCD0Db5ef28C3Bd3a69283372dE923Eb4DA0585F6", relayer="0x48c9bE88706F22838070eE7C4bC74Ad7A8eeF114", + lp="0x0a75395Ff15d9557424b632cEBCac448D66F9779", cashout="0x7dF132Ad2334a667A004049a75a4a8a530dc24F2", + token="0xCf1b86ceD971b88C042C64A9c099377e2738073C", symbol="USDT", decimals=6, + rpc_url="https://rpc-amoy.polygon.technology", testnet=True, + ), + "base_sepolia": Chain( + "base_sepolia", 84532, "BaseSepoliaWETH", "base-sepolia-dev", + core="0x679CC42F69F98631668348d088086359D4217Ffa", relayer="0x2De54cABa6A8198D9Dc51931485Bfdc8017aB0c5", + lp="0x4fE86EddCC22dc992b7994878169201Ec7ea50AB", cashout="0xf52Eb6ec19d81Ea623A7d7Ad397287C0C40f2F37", + token="0x9e09f213Ff75e53D52e9e777A6567A68683E935f", symbol="WETH", decimals=18, + rpc_url="https://sepolia.base.org", testnet=True, + ), +} +"""Every deployment by this library's name for it. Gnosis and Chiliz are +discontinued: the feed still answers for them, with every condition stopped.""" + + +def chain_of(name: str | int | Chain) -> Chain: + """A deployment by name (`polygon`, `base`, ...) or chain id.""" + if isinstance(name, Chain): + return name + key = str(name).strip().lower().replace("-", "_") + if key in CHAINS: + return CHAINS[key] + for chain in CHAINS.values(): + if key == str(chain.chain_id): + return chain + raise BadRequest(f"{VENUE}: unknown chain {name!r}; expected one of {', '.join(CHAINS)}") + + +LIMITER = RateLimiter(5.0, burst=10) +"""The feed publishes no rate limit; kept modest and shared process-wide.""" + +GAMES_PAGE = 10 +"""Games per listing page: the feed's minimum. A game carries up to a few +hundred conditions, so pages stay small.""" + +MAX_PAGES = 200 +"""Most game pages one walk reads.""" + +MAX_GAMES = 10 +"""Games one conditions request asks for.""" + +TRADES_PAGE = 100 +MAX_TRADE_PAGES = 20 +"""Most tape pages one candle read walks back through: 2,000 bets.""" + +PRICE_PLACES = 8 + +CONDITION_ID = re.compile(r"\d{1,80}") +OPEN_STATES = ("Live", "Prematch") +"""The game states the feed lists, live games first.""" + + +# --------------------------------------------------------------------------- +# Prices and ids +# --------------------------------------------------------------------------- + +def odds_value(value: Any) -> Decimal | None: + """The venue's decimal odds, or `None` when there are none to bet at.""" + if value in (None, ""): + return None + try: + odds = Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + return odds if odds > 1 else None + + +def odds_price(value: Any) -> float | None: + """Decimal odds as the price of the outcome's YES: `1 / odds`, to 8 places.""" + odds = odds_value(value) + if odds is None: + return None + return round(float(Decimal(1) / odds), PRICE_PLACES) + + +def market_native(condition_id: str, outcome_id: str | int | None = None) -> str: + """`` for a two-outcome condition, `-` for one outcome of a larger one.""" + return str(condition_id) if outcome_id is None else f"{condition_id}-{outcome_id}" + + +def parse_market_id(native: str) -> tuple[str, str | None]: + """(condition id, outcome id or `None`) from a market's native id.""" + condition_id, sep, outcome_id = str(native).partition("-") + if not CONDITION_ID.fullmatch(condition_id) or (sep and not outcome_id.isdigit()): + raise BadRequest( + f"{VENUE}: {native!r} is not an Azuro market id: expected or -" + ) + return condition_id, (outcome_id if sep else None) + + +def game_of_condition(condition_id: str) -> str | None: + """The game a v3 condition id names. Every feed condition id read so far + is 48 digits whose 5th to 26th are its game's 22-digit id; anything else + is `None` and is looked up by condition instead.""" + if len(condition_id) == 48 and condition_id[4:6] == "10": + return condition_id[4:26] + return None + + +def outcome_order(condition: dict[str, Any]) -> list[dict[str, Any]]: + """A condition's outcomes by `outcomeId`, which fixes YES and NO: the feed + lists them in display order, which differs between its own routes.""" + rows = [o for o in condition.get("outcomes") or [] if isinstance(o, dict) and o.get("outcomeId") is not None] + return sorted(rows, key=lambda o: int(str(o["outcomeId"]))) + + +def is_binary(condition: dict[str, Any]) -> bool: + return len(outcome_order(condition)) == 2 + + +def outcome_odds(outcome: dict[str, Any]) -> Any: + """The odds an outcome is offered at now: the stream's `currentOdds`, the REST feed's `odds`.""" + return outcome.get("currentOdds") if outcome.get("currentOdds") is not None else outcome.get("odds") + + +def takes_bets(condition: dict[str, Any], outcome: dict[str, Any]) -> bool: + return str(condition.get("state") or "") == "Active" and str(outcome.get("state") or "Active") == "Active" + + +def quote_of(condition: dict[str, Any], yes: dict[str, Any], no: dict[str, Any] | None) -> tuple[Quote, Quote]: + """The YES and NO quotes from the pool's odds. Buying YES costs `1 / O` + of the YES outcome; on a two-outcome condition, selling YES is backing the + other outcome, so the YES bid is one minus that outcome's price.""" + ask = odds_price(outcome_odds(yes)) if takes_bets(condition, yes) else None + no_ask = odds_price(outcome_odds(no)) if no is not None and takes_bets(condition, no) else None + bid = round(1 - no_ask, PRICE_PLACES) if no_ask is not None else None + mid = round((bid + ask) / 2, PRICE_PLACES) if bid is not None and ask is not None else None + yes_quote = Quote(bid=bid, ask=ask, mid=mid) + no_quote = Quote( + bid=round(1 - ask, PRICE_PLACES) if ask is not None else None, ask=no_ask, + mid=round(1 - mid, PRICE_PLACES) if mid is not None else None, + ) + return yes_quote, no_quote + + +def seconds_ms(value: Any) -> int | None: + """Unix seconds (as the feed and the subgraph write them) as milliseconds.""" + try: + return int(Decimal(str(value)) * 1000) + except (InvalidOperation, ValueError, TypeError): + return None + + +# --------------------------------------------------------------------------- +# Normalizers +# --------------------------------------------------------------------------- + +GAME_DONE = ("Finished", "Canceled") + + +def status_of(condition: dict[str, Any], game: dict[str, Any] | None = None) -> tuple[str, str, bool]: + """(normalized status, native word, taking bets) for a condition.""" + native = str(condition.get("state") or "") + if native in ("Resolved", "Canceled"): + return "settled", native.lower(), False + game_state = str((game or {}).get("state") or "") + if native == "Active" and game_state not in GAME_DONE: + return "open", "active", True + return "closed", native.lower() or game_state.lower(), False + + +def winner_of(condition: dict[str, Any], yes_id: str, no_id: str | None) -> str | None: + """`yes`, `no` or `void` once the condition is settled, else `None`.""" + if str(condition.get("state") or "") == "Canceled": + return "void" + won = [str(w) for w in condition.get("wonOutcomeIds") or []] + if not won: + won = [str(o.get("outcomeId")) for o in condition.get("outcomes") or [] if str(o.get("state")) == "Won"] + if not won: + return None + if yes_id in won: + return "yes" + if no_id is None or no_id in won: + return "no" + return None + + +def game_title(game: dict[str, Any] | None) -> str: + game = game or {} + if game.get("title"): + return str(game["title"]) + names = [p.get("name") for p in game.get("participants") or [] if isinstance(p, dict) and p.get("name")] + return " - ".join(names) if names else str(game.get("gameId") or "") + + +def tags_of(game: dict[str, Any] | None) -> list[str]: + """Sport, league and country, lowercase, without duplicates.""" + game = game or {} + words = [(game.get("sport") or {}).get("name"), (game.get("league") or {}).get("name"), + (game.get("country") or {}).get("name")] + seen: dict[str, None] = {} + for word in words: + if word and str(word).strip(): + seen.setdefault(str(word).strip().lower(), None) + return list(seen) + + +def _game_summary(game: dict[str, Any] | None) -> dict[str, Any]: + game = game or {} + return { + "game_id": game.get("gameId"), "title": game.get("title"), "state": game.get("state"), + "starts_at": seconds_ms(game.get("startsAt")), "slug": game.get("slug"), + "sport": (game.get("sport") or {}).get("slug"), "league": (game.get("league") or {}).get("slug"), + "country": (game.get("country") or {}).get("slug"), + } + + +def _market( + condition: dict[str, Any], game: dict[str, Any] | None, *, native: str, title: str, description: str, + yes: Outcome, no: Outcome, outcome_label: str, winner: str | None, extra: dict[str, Any], +) -> Market: + status, native_status, accepting = status_of(condition, game) + game = game or {} + game_id = str(game.get("gameId") or (condition.get("game") or {}).get("gameId") + or game_of_condition(str(condition.get("conditionId"))) or "") + tags = tags_of(game) + league = game.get("league") or {} + return Market( + id=ids.qualify(VENUE, native), + venue=VENUE, + venue_market_id=native, + event_id=ids.qualify(VENUE, game_id) if game_id else None, + title=title, + description=description, + slug=None, + yes=yes, + no=no, + status=status, # type: ignore[arg-type] + native_status=native_status, + active=accepting, + market_type="binary", + open_timestamp=None, open_datetime=None, close_timestamp=None, close_datetime=None, + resolution_timestamp=None, resolution_datetime=None, + tick_size=None, + face_value=1.0, + book_model="shared_complement", + stats=MarketStats(), + url=None, + image_url=None, + category=str((game.get("sport") or {}).get("slug") or "") or None, + tags=tags, + series_id=str(league.get("id") or league.get("slug") or "") or None, + outcome_label=outcome_label, + neg_risk=False, + settlement_sources=[], + info={ + "condition": condition, + "condition_id": str(condition.get("conditionId")), + "condition_title": condition.get("title"), + "category": condition.get("category"), + "market_type_id": condition.get("marketId"), + "hidden": condition.get("hidden"), + "prematch_enabled": condition.get("isPrematchEnabled"), + "live_enabled": condition.get("isLiveEnabled"), + "combo_allowed": None if condition.get("isExpressForbidden") is None else not condition["isExpressForbidden"], + "game": _game_summary(game), + "game_time": seconds_ms(game.get("startsAt")), + "winning_outcome": winner, + **extra, + }, + ) + + +def normalize_condition(condition: dict[str, Any], game: dict[str, Any] | None = None) -> list[Market]: + """A condition's Synpath markets: one for a two-outcome condition, one per + outcome for a larger one. See the module docstring.""" + outcomes = outcome_order(condition) + if len(outcomes) < 2: + return [] + condition_id = str(condition["conditionId"]) + head = f"{game_title(game) or condition_id} — {condition.get('title') or 'Condition'}" + if len(outcomes) == 2: + one, two = outcomes + yes_quote, no_quote = quote_of(condition, one, two) + yes_id, no_id = str(one["outcomeId"]), str(two["outcomeId"]) + label_one, label_two = str(one.get("title") or "Yes"), str(two.get("title") or "No") + return [_market( + condition, game, native=market_native(condition_id), + title=f"{head}: {label_one}", + description=f"{condition.get('title') or 'Condition'}: {label_one} or {label_two}.", + yes=Outcome(label=label_one, quote=yes_quote, info=_outcome_info(one)), + no=Outcome(label=label_two, quote=no_quote, info=_outcome_info(two)), + outcome_label=label_one, winner=winner_of(condition, yes_id, no_id), + extra={"outcome_ids": {"yes": yes_id, "no": no_id}, "outcome_count": 2, + "odds": {"yes": outcome_odds(one), "no": outcome_odds(two)}}, + )] + markets = [] + names = [str(o.get("title") or o["outcomeId"]) for o in outcomes] + for outcome in outcomes: + outcome_id = str(outcome["outcomeId"]) + label = str(outcome.get("title") or outcome_id) + yes_quote, no_quote = quote_of(condition, outcome, None) + markets.append(_market( + condition, game, native=market_native(condition_id, outcome_id), + title=f"{head}: {label}", + description=f"{condition.get('title') or 'Condition'}: does {label} win? One of {len(outcomes)} " + f"outcomes ({', '.join(names)}).", + yes=Outcome(label=label, quote=yes_quote, info=_outcome_info(outcome)), + no=Outcome(label=f"Not {label}", quote=no_quote, info={"offered": False}), + outcome_label=label, winner=winner_of(condition, outcome_id, None), + extra={"outcome_ids": {"yes": outcome_id, "no": None}, "outcome_count": len(outcomes), + "odds": {"yes": outcome_odds(outcome), "no": None}}, + )) + return markets + + +def _outcome_info(outcome: dict[str, Any]) -> dict[str, Any]: + return { + "outcome_id": str(outcome.get("outcomeId")), "odds": outcome_odds(outcome), "state": outcome.get("state"), + "hidden": outcome.get("hidden"), "point": outcome.get("point"), + } + + +def event_status(game: dict[str, Any], markets: list[Market]) -> str: + state = str(game.get("state") or "") + if state == "Canceled" or (markets and all(m.status == "settled" for m in markets)): + return "settled" + if state in OPEN_STATES and any(m.status == "open" for m in markets): + return "open" + if state in OPEN_STATES and not markets: + return "open" + return "closed" + + +def normalize_event(game: dict[str, Any], conditions: list[dict[str, Any]]) -> Event: + """A game with its conditions' markets.""" + game_id = str(game.get("gameId") or game.get("id")) + markets = [m for c in conditions for m in normalize_condition(c, game)] + tags = tags_of(game) + league = game.get("league") or {} + return Event( + id=ids.qualify(VENUE, game_id), + venue=VENUE, + venue_event_id=game_id, + title=game_title(game), + description=None, + slug=game.get("slug"), + markets=markets, + status=event_status(game, markets), # type: ignore[arg-type] + native_status=str(game.get("state") or "").lower() or None, + category=str((game.get("sport") or {}).get("slug") or "") or None, + tags=tags, + series_id=str(league.get("id") or league.get("slug") or "") or None, + mutually_exclusive=None, + close_timestamp=None, + close_datetime=None, + url=None, + image_url=None, + settlement_sources=[], + info={ + **_game_summary(game), + "turnover": game.get("turnover"), + "participants": [p.get("name") for p in game.get("participants") or [] if isinstance(p, dict)], + "game": game, + }, + ) + + +def normalize_trade(bet: dict[str, Any], *, market_id: str, yes_outcome: str, binary: bool) -> Trade | None: + """One single bet from the subgraph, in the YES price with the bettor's + side: backing the YES outcome at odds `O` buys YES at `1 / O`; backing + the other outcome of a two-outcome condition sells YES at `1 - 1 / O`. + The contracts are the bet's potential payout, one token each.""" + selections = bet.get("selections") or [] + if len(selections) != 1: + return None + outcome_id = str(((selections[0] or {}).get("outcome") or {}).get("outcomeId")) + buying = outcome_id == yes_outcome + if not buying and not binary: + return None + odds = odds_value(selections[0].get("odds") or bet.get("odds")) + if odds is None: + return None + price = Decimal(1) / odds + if not buying: + price = Decimal(1) - price + stamp = seconds_ms(bet.get("createdBlockTimestamp")) or 0 + payout = bet.get("potentialPayout") + try: + quantity = float(Decimal(str(payout))) if payout not in (None, "") else float(Decimal(str(bet["amount"])) * odds) + except (InvalidOperation, KeyError, ValueError): + quantity = 0.0 + return Trade( + id=str(bet.get("id") or bet.get("betId") or stamp), + market_id=market_id, + timestamp=stamp, + datetime=iso(stamp) or "", + price=round(float(price), PRICE_PLACES), + quantity=quantity, + side="buy" if buying else "sell", + info={k: v for k, v in bet.items() if k not in ("bettor", "actor", "owner")}, + ) + + +def fee_schedule_of(market_id: str, *, chain: Chain | None = None) -> FeeSchedule: + """No fee on top of the odds: the pool's margin is in them. A bet placed + through the relayer pays it a flat fee for the transaction, read per bet.""" + chain = chain or CHAINS["polygon"] + return FeeSchedule( + venue=VENUE, scope="market", scope_id=market_id, fee_type="pool_margin", + taker_rate=0.0, maker_rate=None, + info={"currency": chain.symbol, "chain": chain.name, + "charged": "the pool's margin is inside its quoted odds; the relayer takes a flat fee per bet " + "for the transaction (/bet/gas-info)"}, + ) + + +# --------------------------------------------------------------------------- +# GraphQL +# --------------------------------------------------------------------------- + +BETS_QUERY = """query Bets($first: Int!, $where: V3_Bet_filter!) { + v3Bets(first: $first, where: $where, orderBy: createdBlockTimestamp, orderDirection: desc, subgraphError: allow) { + id betId amount odds potentialPayout status result type createdBlockTimestamp createdTxHash + selections { odds outcome { outcomeId condition { conditionId gameId } } } + } +}""" + + +def bets_where(condition_id: str, cursor: str | None) -> dict[str, Any]: + """The subgraph filter for one condition's single bets, older than the + cursor. The cursor is `:`, + so bets sharing the boundary second are neither repeated nor lost.""" + where: dict[str, Any] = {"_conditionIds_contains": [condition_id], "type": "Ordinar"} + if cursor: + stamp, _, seen = cursor.partition(":") + if not stamp.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + where["createdBlockTimestamp_lte"] = stamp + if seen: + where["id_not_in"] = seen.split(",") + return where + + +def next_bets_cursor(rows: list[dict[str, Any]], cursor: str | None, *, full: bool) -> str | None: + if not full or not rows: + return None + oldest = min(int(r.get("createdBlockTimestamp") or 0) for r in rows) + seen = [str(r["id"]) for r in rows if int(r.get("createdBlockTimestamp") or 0) == oldest] + if cursor: + stamp, _, before = cursor.partition(":") + if stamp == str(oldest) and before: + seen = before.split(",") + seen + return f"{oldest}:{','.join(seen)}" + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class Azuro(Exchange): + """Azuro, read-only. No key needed. + + ```python + import synpath + + azuro = synpath.Azuro() # the Polygon pool; Azuro(chain="base") for Base + markets = azuro.fetch_markets(limit=5) + print(markets[0].title, markets[0].yes.quote.ask) + ``` + """ + + id = VENUE + name = "Azuro" + book_model = "shared_complement" + has: dict[str, Capability] = { + "fetch_markets": True, + "fetch_events": True, + "fetch_market": True, + "fetch_markets_by_ids": True, + # The feed orders games by start time or turnover, not markets. + "sort": False, + # A pool has no book: the quote as one level a side, sized by the + # pool's limit on the outcome. + "fetch_order_book": "partial", + # One request per outcome for the limits. + "fetch_order_books": True, + # Every single bet, from the bets subgraph. + "fetch_trades": True, + # Built from that tape. + "fetch_ohlcv": "partial", + "fetch_series": False, + "fetch_fee_schedule": True, + "search": True, + "match_market": False, + "match_event": False, + } + + def __init__( + self, *, chain: str | int | Chain = "polygon", api_url: str | None = None, subgraph_url: str | None = None, + timeout: float = 30.0, limiter: RateLimiter | None = LIMITER, client: Any = None, + ): + """`chain` picks the pool whose state and odds are read: `polygon` + (USDT, the default), `base` (WETH), the testnets `polygon_amoy` and + `base_sepolia`, or the discontinued `gnosis` and `chiliz`.""" + self.chain = chain_of(chain) + client = client or httpx.Client(timeout=timeout, follow_redirects=True) + self.http = HttpClient(api_url or self.chain.api_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + self.graph = HttpClient(subgraph_url or self.chain.subgraph_url, limiter=limiter, timeout=timeout, + client=client, venue=VENUE) + + @property + def environment(self) -> str: + return self.chain.environment + + # -- feed calls ----------------------------------------------------------- + + def _games_page(self, state: str, page: int, per_page: int, **filters: Any) -> dict[str, Any]: + data = self.http.get("/market-manager/games-by-filters", { + "environment": self.environment, "gameState": state, "orderBy": "startsAt", "orderDirection": "asc", + "perPage": max(GAMES_PAGE, min(per_page, MAX_PAGE_LIMIT)), "page": page, + "sportSlug": filters.get("sport"), "leagueSlug": filters.get("league"), + }) + return data if isinstance(data, dict) else {} + + def _conditions(self, game_ids: list[str]) -> dict[str, list[dict[str, Any]]]: + """Each game's conditions, in the feed's order, ten games a request.""" + out: dict[str, list[dict[str, Any]]] = {g: [] for g in game_ids} + for start in range(0, len(game_ids), MAX_GAMES): + chunk = game_ids[start:start + MAX_GAMES] + data = self.http.post("/market-manager/conditions-by-game-ids", { + "gameIds": chunk, "environment": self.environment, "extended": True, + }) + for row in (data or {}).get("conditions") or [] if isinstance(data, dict) else []: + if not isinstance(row, dict) or not row.get("conditionId"): + continue + game_id = str((row.get("game") or {}).get("gameId") or game_of_condition(str(row["conditionId"])) or "") + out.setdefault(game_id, []).append(row) + return out + + def _games(self, game_ids: list[str]) -> dict[str, dict[str, Any]]: + if not game_ids: + return {} + data = self.http.post("/market-manager/games-by-ids", {"gameIds": game_ids}) + rows = (data or {}).get("games") or [] if isinstance(data, dict) else [] + return {str(g.get("gameId")): g for g in rows if isinstance(g, dict) and g.get("gameId")} + + def fetch_condition_states(self, condition_ids: list[str]) -> list[dict[str, Any]]: + """Fresh state and odds of many conditions in one request, without + their games (`/market-manager/condition-batch`).""" + if not condition_ids: + return [] + data = self.http.post("/market-manager/condition-batch", { + "conditionIds": list(dict.fromkeys(condition_ids)), "environment": self.environment, + }) + rows = (data or {}).get("conditions") or [] if isinstance(data, dict) else [] + return [r for r in rows if isinstance(r, dict) and r.get("conditionId")] + + def condition_outcomes(self, condition_ids: list[str]) -> dict[str, list[str]]: + """Each condition's outcome ids, lowest first: what decides its + markets' ids and which outcome is YES.""" + return {str(c["conditionId"]): [str(o["outcomeId"]) for o in outcome_order(c)] + for c in self.fetch_condition_states(condition_ids)} + + def fetch_sports(self, *, sport_hub: str | None = None) -> list[dict[str, Any]]: + """Sports, their countries and leagues, with how many games each has + open, live and prematch (`/market-manager/navigation`), as the feed + gives them. `sport_hub` is `sports` or `esports`.""" + data = self.http.get("/market-manager/navigation", {"environment": self.environment, "sportHub": sport_hub}) + return list((data or {}).get("sports") or []) if isinstance(data, dict) else [] + + def fetch_limits(self, market_id: str, *, side: str = "buy", account: str | None = None) -> dict[str, Any]: + """The pool's limits on one bet (`/bet/calculation`): `min_bet` and + `max_bet` in the bet token, `max_payout` the most it pays out, which + is the most contracts it sells. `side="sell"` asks for the NO outcome + of a two-outcome market. `account` (a wallet address) makes `max_bet` + that wallet's own.""" + condition_id, outcome_id = self._selection(market_id, side) + body: dict[str, Any] = {"bets": [{"conditionId": condition_id, "outcomeId": int(outcome_id)}], + "environment": self.environment} + if account: + body["wallet"] = account + data = self.http.post("/bet/calculation", body) + raw = (data or {}).get("response", data) if isinstance(data, dict) else {} + raw = raw if isinstance(raw, dict) else {} + return { + "condition_id": condition_id, "outcome_id": outcome_id, "currency": self.chain.symbol, + "min_bet": _number(raw.get("minBet")), "max_bet": _number(raw.get("maxBet")), + "max_payout": _number(raw.get("maxPayout")), "info": raw, + } + + def _selection(self, market_id: str, side: str) -> tuple[str, str]: + """(condition id, outcome id) a side of a market bets.""" + condition_id, outcome_id = parse_market_id(self.native(market_id)) + if outcome_id is not None: + if side != "buy": + raise BadRequest(f"{VENUE}: only YES is sold on one outcome of a {VENUE} condition; there is no NO to bet") + return condition_id, outcome_id + outcomes = self.condition_outcomes([condition_id]).get(condition_id) + if not outcomes: + raise MarketNotFound(f"{VENUE}: no condition {condition_id}") + if len(outcomes) != 2: + raise BadRequest(_one_per_outcome(condition_id, outcomes)) + return condition_id, outcomes[0] if side == "buy" else outcomes[1] + + # -- catalog -------------------------------------------------------------- + + def fetch_markets( + self, *, query: str | None = None, limit: int | None = None, + cursor: str | None = None, status: str = "open", + sort: str | None = None, sport: str | None = None, league: str | None = None, + ) -> Page[Market]: + """One page of open markets, live games first, then upcoming ones by + start time; `sport` and `league` are the feed's slugs (`football`, + `premier-league`). With `query`, the markets of the games whose + teams, league or country match (the first ten games).""" + check_status(status) + if status != "open": + raise NotSupported(f"{VENUE}: the feed lists only open games; look up a settled market with fetch_market") + check_sort(sort, venue=VENUE, supported=False) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + if query: + games = self._search(query, 1)["games"] + conditions = self._conditions([str(g["gameId"]) for g in games]) + markets = [m for g in games for c in conditions.get(str(g["gameId"]), []) + for m in normalize_condition(c, g) if m.status == "open"] + return Page(markets[:wanted], next_cursor=None) + state, page, game_index, skip = _cursor(cursor, parts=4) + out: list[Market] = [] + for _ in range(MAX_PAGES): + data = self._games_page(state, page, GAMES_PAGE, sport=sport, league=league) + games = [g for g in data.get("games") or [] if isinstance(g, dict) and g.get("gameId")] + for index in range(game_index, len(games)): + game = games[index] + rows = self._conditions([str(game["gameId"])]).get(str(game["gameId"]), []) + batch = [m for c in rows for m in normalize_condition(c, game) if m.status == "open"][skip:] + room = wanted - len(out) + out += batch[:room] + if len(batch) > room: + return Page(out, next_cursor=f"{state}:{page}:{index}:{skip + room}") + skip = 0 + if len(out) >= wanted: + following = _following(state, page, index + 1, len(games), data) + return Page(out, next_cursor=following) + game_index = 0 + following = _following(state, page, len(games), len(games), data) + if following is None: + return Page(out, next_cursor=None) + state, page, game_index, skip = _cursor(following, parts=4) + return Page(out, next_cursor=f"{state}:{page}:0:0") + + def _search(self, query: str, page: int) -> dict[str, Any]: + if len(query.strip()) < 3: + raise BadRequest(f"{VENUE}: a search needs at least 3 characters") + data = self.http.get("/market-manager/search", { + "environment": self.environment, "request": query.strip(), "page": page, "perPage": GAMES_PAGE, + }) + data = data if isinstance(data, dict) else {} + data["games"] = [g for g in data.get("games") or [] if isinstance(g, dict) and g.get("gameId")] + return data + + def fetch_events( + self, *, query: str | None = None, limit: int | None = None, + cursor: str | None = None, status: str = "open", sport: str | None = None, league: str | None = None, + ) -> Page[Event]: + """Open games with every condition's markets, live games first, then + upcoming ones by start time. With `query`, the games whose teams, + league or country match, ten a page.""" + check_status(status) + if status != "open": + raise NotSupported(f"{VENUE}: the feed lists only open games; look one up with fetch_market") + wanted = page_limit(limit) or GAMES_PAGE + if query: + page = int(cursor) if cursor and cursor.isdigit() else 1 + if cursor and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + data = self._search(query, page) + games = data["games"][:wanted] + conditions = self._conditions([str(g["gameId"]) for g in games]) + events = [normalize_event(g, conditions.get(str(g["gameId"]), [])) for g in games] + more = int(data.get("totalPages") or 0) > page + return Page(events, next_cursor=str(page + 1) if more else None) + state, page, _, _ = _cursor(cursor, parts=2) + per_page = max(GAMES_PAGE, wanted) + data = self._games_page(state, page, per_page, sport=sport, league=league) + games = [g for g in data.get("games") or [] if isinstance(g, dict) and g.get("gameId")][:wanted] + conditions = self._conditions([str(g["gameId"]) for g in games]) + events = [normalize_event(g, conditions.get(str(g["gameId"]), [])) for g in games] + following = _following(state, page, 0, 0, data) + return Page(events, next_cursor=":".join(following.split(":")[:2]) if following else None) + + def iter_events(self, *, status: str = "open") -> Iterator[Event]: + cursor: str | None = None + for _ in range(MAX_PAGES): + page = self.fetch_events(cursor=cursor, status=status) + yield from page + cursor = page.next_cursor + if not cursor: + return + + def fetch_market(self, market_id: str) -> Market: + """One market by id, open or settled.""" + native = self.native(market_id) + found = self.fetch_markets_by_ids([native]) + if found: + return found[0] + condition_id, outcome_id = parse_market_id(native) + states = self.fetch_condition_states([condition_id]) + if not states: + raise MarketNotFound(f"{VENUE}: no market {native}") + outcomes = [str(o["outcomeId"]) for o in outcome_order(states[0])] + if outcome_id is None and len(outcomes) != 2: + raise BadRequest(_one_per_outcome(condition_id, outcomes)) + if outcome_id is not None and len(outcomes) == 2: + raise BadRequest(f"{VENUE}: {condition_id} has two outcomes and is one market, {VENUE}:{condition_id}") + raise MarketNotFound(f"{VENUE}: no market {native}") + + def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: + """Many markets in the order asked for: each game's conditions are read + once, ten games a request. Ids the feed does not know are left out.""" + natives = list(dict.fromkeys(self.native(m) for m in market_ids)) + wanted = {n: parse_market_id(n) for n in natives} + games: dict[str, None] = {} + orphans: list[str] = [] + for condition_id, _ in wanted.values(): + game_id = game_of_condition(condition_id) + if game_id: + games.setdefault(game_id, None) + else: + orphans.append(condition_id) + by_id: dict[str, Market] = {} + if games: + conditions = self._conditions(list(games)) + details = self._games(list(games)) + for game_id, rows in conditions.items(): + for row in rows: + for market in normalize_condition(row, details.get(game_id)): + by_id[market.venue_market_id] = market + missing = [c for n, (c, _) in wanted.items() if n not in by_id] + if missing: + rows = self.fetch_condition_states(list(dict.fromkeys(missing + orphans))) + for row in rows: + for market in normalize_condition(row, None): + by_id.setdefault(market.venue_market_id, market) + return [by_id[n] for n in natives if n in by_id] + + def refresh_quotes(self, market: Market) -> Market: + """`market` with its quotes read from the pool's current odds.""" + condition_id, outcome_id = parse_market_id(market.venue_market_id) + rows = self.fetch_condition_states([condition_id]) + if not rows: + raise MarketNotFound(f"{VENUE}: no condition {condition_id}") + fresh = next((m for m in normalize_condition(rows[0], None) if m.venue_market_id == market.venue_market_id), None) + if fresh is None: + raise MarketNotFound(f"{VENUE}: no market {market.venue_market_id}") + return market.model_copy(update={ + "yes": market.yes.model_copy(update={"quote": fresh.yes.quote}), + "no": market.no.model_copy(update={"quote": fresh.no.quote}), + }) + + # -- market data ---------------------------------------------------------- + + def fetch_order_book( + self, market_id: str, *, side: BookSide = "yes", depth: int | None = None, + ) -> OrderBook: + """The pool's quote as a book: on YES an ask at `1 / O` of the YES + outcome and, on a two-outcome market, a bid at one minus the price of + the other, each sized by the pool's limit on that outcome in contracts + (its maximum payout). `side="no"` mirrors it. Two requests for the + odds and limits of each outcome quoted.""" + if side not in ("yes", "no"): + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + native = self.native(market_id) + condition_id, outcome_id = parse_market_id(native) + rows = self.fetch_condition_states([condition_id]) + if not rows: + raise MarketNotFound(f"{VENUE}: no order book for {native}: no condition {condition_id}") + condition = rows[0] + outcomes = outcome_order(condition) + if outcome_id is None and len(outcomes) != 2: + raise BadRequest(_one_per_outcome(condition_id, [str(o["outcomeId"]) for o in outcomes])) + if outcome_id is None: + yes, no = outcomes + else: + yes = next((o for o in outcomes if str(o["outcomeId"]) == outcome_id), None) + no = None + if yes is None: + raise MarketNotFound(f"{VENUE}: no outcome {outcome_id} on condition {condition_id}") + limits: dict[str, Any] = {} + asks, bids = [], [] + ask = odds_price(outcome_odds(yes)) if takes_bets(condition, yes) else None + if ask is not None: + limits["yes"] = self.fetch_limits(native, side="buy") + size = contracts_of_limits(limits["yes"], outcome_odds(yes)) + if size: + asks.append(OrderLevel(price=ask, size=size)) + no_price = odds_price(outcome_odds(no)) if no is not None and takes_bets(condition, no) else None + if no_price is not None: + limits["no"] = self.fetch_limits(native, side="sell") + size = contracts_of_limits(limits["no"], outcome_odds(no)) + if size: + bids.append(OrderLevel(price=round(1 - no_price, PRICE_PLACES), size=size)) + derived = side == "no" + if derived: + bids, asks = ( + [OrderLevel(price=round(1 - level.price, PRICE_PLACES), size=level.size) for level in asks], + [OrderLevel(price=round(1 - level.price, PRICE_PLACES), size=level.size) for level in bids], + ) + return OrderBook( + market_id=self.qualify(native), side=side, venue=VENUE, bids=bids, asks=asks, + timestamp=None, datetime=None, book_model="shared_complement", derived=derived, + depth_scope="top_n", + info={"pool": True, "condition_state": condition.get("state"), + "odds": {"yes": outcome_odds(yes), "no": outcome_odds(no) if no is not None else None}, + "limits": {k: {kk: vv for kk, vv in v.items() if kk != "info"} for k, v in limits.items()}}, + ) + + def fetch_order_books( + self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None, + ) -> dict[str, OrderBook]: + """Books for many markets, three requests or fewer each.""" + return {self.qualify(self.native(m)): self.fetch_order_book(m, side=side, depth=depth) + for m in dict.fromkeys(market_ids)} + + def _tape(self, condition_id: str, *, first: int, cursor: str | None) -> tuple[list[dict[str, Any]], str | None]: + data = self.graph.post("", {"query": BETS_QUERY, "variables": { + "first": first, "where": bets_where(condition_id, cursor), + }}) + if isinstance(data, dict) and data.get("errors"): + raise ExchangeError(f"{VENUE}: bets subgraph: {data['errors']}", body=data) + rows = list(((data or {}).get("data") or {}).get("v3Bets") or []) if isinstance(data, dict) else [] + return rows, next_bets_cursor(rows, cursor, full=len(rows) >= first) + + def _trades_of(self, native: str, rows: list[dict[str, Any]], outcomes: list[str]) -> list[Trade]: + _, outcome_id = parse_market_id(native) + binary = outcome_id is None + yes = outcomes[0] if binary and outcomes else str(outcome_id) + trades = [normalize_trade(row, market_id=self.qualify(native), yes_outcome=yes, binary=binary) for row in rows] + return [t for t in trades if t is not None] + + def _outcomes_for_tape(self, native: str) -> list[str]: + condition_id, outcome_id = parse_market_id(native) + if outcome_id is not None: + return [outcome_id] + outcomes = self.condition_outcomes([condition_id]).get(condition_id) or [] + if len(outcomes) > 2: + raise BadRequest(_one_per_outcome(condition_id, outcomes)) + return outcomes + + def fetch_trades( + self, market_id: str, *, since: int | None = None, limit: int | None = None, + cursor: str | None = None, + ) -> Page[Trade]: + """The market's single bets from the bets subgraph, a page newest + first and returned oldest first, in the YES price with the bettor's + side. Combo bets are left out: no one price of theirs is this market's.""" + native = self.native(market_id) + condition_id, _ = parse_market_id(native) + outcomes = self._outcomes_for_tape(native) + rows, following = self._tape(condition_id, first=page_limit(limit) or 50, cursor=cursor) + trades = self._trades_of(native, rows, outcomes) + if since: + trades = [t for t in trades if t.timestamp >= since] + return Page(sorted(trades, key=lambda t: t.timestamp), next_cursor=following) + + def fetch_ohlcv( + self, market_id: str, *, timeframe: str = "1h", since: int | None = None, + until: int | None = None, limit: int | None = None, + ) -> list[Candle]: + """Bars in the YES price built from the market's single bets, with + their volume in contracts (`price_source="trade"`). The tape is read + back page by page until `since`, at most 2,000 bets.""" + from .sx_bet import candles_from_trades + + native = self.native(market_id) + condition_id, _ = parse_market_id(native) + outcomes = self._outcomes_for_tape(native) + seconds = timeframe_seconds(timeframe) + trades: list[Trade] = [] + cursor = None + for _ in range(MAX_TRADE_PAGES): + rows, cursor = self._tape(condition_id, first=TRADES_PAGE, cursor=cursor) + batch = self._trades_of(native, rows, outcomes) + trades += batch + oldest = min((int(r.get("createdBlockTimestamp") or 0) * 1000 for r in rows), default=0) + if not cursor or (since and rows and oldest < since): + break + candles = candles_from_trades(trades, interval_seconds=seconds) + if since: + candles = [c for c in candles if c.timestamp + seconds * 1000 > since] + if until: + candles = [c for c in candles if c.timestamp <= until] + return pick_bars(candles, since=since, limit=limit) + + # -- reference ------------------------------------------------------------ + + def fetch_fee_schedule(self, market_id: str) -> FeeSchedule: + return fee_schedule_of(self.native(market_id), chain=self.chain) + + def close(self) -> None: + self.http.close() + + +def contracts_of_limits(limits: dict[str, Any], odds: Any) -> float | None: + """The most contracts the pool sells on one outcome: its maximum payout, + or its maximum stake times the odds when that is smaller.""" + sizes = [] + if limits.get("max_payout") is not None: + sizes.append(float(limits["max_payout"])) + price = odds_value(odds) + if limits.get("max_bet") is not None and price is not None: + sizes.append(float(Decimal(str(limits["max_bet"])) * price)) + sizes = [s for s in sizes if s > 0] + return round(min(sizes), 6) if sizes else None + + +def _number(value: Any) -> float | None: + try: + return float(Decimal(str(value))) if value not in (None, "") else None + except (InvalidOperation, ValueError): + return None + + +def _one_per_outcome(condition_id: str, outcomes: list[str]) -> str: + names = ", ".join(f"{VENUE}:{condition_id}-{o}" for o in outcomes) + return f"{VENUE}: condition {condition_id} has {len(outcomes)} outcomes, one market each: {names}" + + +def _cursor(cursor: str | None, *, parts: int) -> tuple[str, int, int, int]: + """`state:page[:game:skip]`: the game state being walked (`Live`, then + `Prematch`), its 1-based page, and how far into that page the last call got.""" + if not cursor: + return OPEN_STATES[0], 1, 0, 0 + pieces = cursor.split(":") + if len(pieces) != parts or pieces[0] not in OPEN_STATES or not all(p.isdigit() for p in pieces[1:]): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + numbers = [int(p) for p in pieces[1:]] + [0] * (3 - (parts - 1)) + return pieces[0], max(1, numbers[0]), numbers[1], numbers[2] + + +def _following(state: str, page: int, index: int, count: int, data: dict[str, Any]) -> str | None: + """Where the walk goes next: the rest of this page, the next page, or the + next game state once this one's pages are done.""" + if index < count: + return f"{state}:{page}:{index}:0" + total = int(data.get("totalPages") or 0) + if page < total: + return f"{state}:{page + 1}:0:0" + position = OPEN_STATES.index(state) + if position + 1 < len(OPEN_STATES): + return f"{OPEN_STATES[position + 1]}:1:0:0" + return None diff --git a/synpath/client.py b/synpath/client.py index 20f063a..2c261a7 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -383,7 +383,13 @@ def _sx_bet(creds: Credentials) -> TradingExchange: return SxBetTrading(creds) # type: ignore[arg-type] +def _azuro(creds: Credentials) -> TradingExchange: + from .trading.azuro import AzuroTrading + return AzuroTrading(creds) # type: ignore[arg-type] + + _TRADING = { "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, + "azuro": _azuro, } diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..f1602fd 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -65,6 +65,7 @@ "predict_fun": ("..trading.predict_fun", "PredictFunTrading"), "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), + "azuro": ("..trading.azuro", "AzuroTrading"), } diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..5da9999 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -164,6 +164,13 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] # login, when configured, only undelays the quotes. from ..ws.smarkets import SmarketsMarketStream out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) + elif venue == "azuro": + # Public odds over the feed's socket; bets are read back by + # polling, since the feed streams no account channel. + from ..ws.azuro import AzuroMarketStream + chain = getattr(creds, "chain", "polygon") + out[venue] = VenueStreams(market=AzuroMarketStream(chain=chain, catalog=getattr(adapter, "catalog", None)), + private=None) else: log.info("synpath.engine.feeds: no stream for %s; its orders reach the engine by polling", venue) return out diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..7020ee3 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -4,7 +4,8 @@ the venue's own id, separated by a colon: `kalshi:KXFEDDECISION-26SEP-C25`, `polymarket:2252244`, `polymarket_us:tec-mlb-nlchamp-2026-09-27-atl`, `opinion:5342`, `hyperliquid:7173`, `betfair:1.234567890-47972`, -`smarkets:164973231-456060183`. Nothing is hashed or re-keyed, so the part +`smarkets:164973231-456060183`, +`azuro:600610060000000000943019940000000000001802657415`. Nothing is hashed or re-keyed, so the part after the colon is what the venue's own website and API call the thing. The venue in the id is what lets one field route a call: `synpath.Client` @@ -16,7 +17,7 @@ """ from __future__ import annotations -VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") +VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets", "azuro") """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.""" diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 6743665..797655b 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -35,7 +35,7 @@ log = logging.getLogger("synpath.serve") -VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet") +VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "azuro") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index aad1025..28665c5 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -81,12 +81,15 @@ def __getattr__(name: str): if name == "SxBetTrading": from .sx_bet import SxBetTrading return SxBetTrading + if name == "AzuroTrading": + from .azuro import AzuroTrading + return AzuroTrading raise AttributeError(name) __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "AzuroTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index ab2f240..6415e45 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -52,7 +52,7 @@ def doctor(dotenv: str | None) -> int: continue if creds is None: names = ENV_NAMES[venue] - hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair") else "" + hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "azuro") else "" rows.append((venue, "not configured", f"set {names[0]}{hint}")) continue stage = getattr(creds, "env", "-") @@ -91,6 +91,10 @@ def doctor(dotenv: str | None) -> int: stage = creds.jurisdiction login = "certificate" if creds.cert_path else "interactive" identity = f"user {creds.username}, app key loaded, {login} login" + elif venue == "azuro": + stage = creds.chain + affiliate = f", affiliate {creds.affiliate}" if creds.affiliate else "" + identity = f"wallet {creds.address}{affiliate}" elif venue == "smarkets": stage = "login" identity = f"user {creds.username}: undelayed quotes; betting needs approved API access, not supported yet" diff --git a/synpath/trading/azuro.py b/synpath/trading/azuro.py new file mode 100644 index 0000000..a9c532d --- /dev/null +++ b/synpath/trading/azuro.py @@ -0,0 +1,722 @@ +"""Azuro bets, and the venue's bet records on the YES leg. + +Built to `@azuro-org/toolkit` 7.2.0 and the Azuro backend API: bets are +signed off chain as EIP-712 messages and placed on chain by Azuro's relayer +(`azuro_signing`). Signing is checked against digests and signatures that +toolkit and viem produce. + +**One credential: the wallet.** Its private key signs every bet and cash-out, +and its bet token pays for them (USDT on Polygon, WETH on Base). Before the +first bet the wallet must allow the relayer to spend the token (`approve()`, +an on-chain transaction the wallet pays gas for; `fetch_allowance()` reads it). +The relayer takes its fee, a flat amount per bet read from `/bet/gas-info`, +from the same allowance. + +**A bet is a market order with a worst price.** The pool takes it whole at +the odds it quotes when the bet reaches the chain, or not at all, so every +order here is `market` (or a `limit` that is `ioc` or `fok`, which is the +same thing) and nothing rests. The price given is the worst accepted: +`buy` backs the market's YES outcome with minimum odds `1 / price`; `sell` +backs the other outcome of a two-outcome market with minimum odds +`1 / (1 - price)`. One outcome of a larger condition has no NO to sell. An +order for `n` contracts stakes `n` times the price paid, so at the quoted odds +or better it returns at least `n` (a contract pays one token). + +**Placing is asynchronous.** The API answers at once with the bet order's id +in state `Created`; the relayer sends it to the chain and the order becomes +`Accepted` (filled) or `Rejected`. `create_order` returns the order pending; +`fetch_order` and `wait_for_order` follow it. A placed bet cannot be cancelled, +but where the venue offers it a bet can be cashed out before it settles +(`fetch_cashout_quote`, `cash_out`). + +**Positions** net a market's accepted, unsettled single bets on the YES leg: +the contracts on the YES outcome less those on the other. A winning bet's +payout is claimed on chain from the pool; this library does not send that +transaction. +""" +from __future__ import annotations + +import asyncio +import time +from datetime import datetime +from decimal import Decimal +from typing import Any + +from .. import ids +from ..azuro import Azuro, Chain, chain_of, game_of_condition, parse_market_id +from ..base import AsyncHttpClient, Capability +from ..errors import BadRequest, ExchangeError, MarketNotFound, NotSupported +from ..types import Page +from . import azuro_signing as sig +from .base import TradingExchange +from .credentials import AzuroCredentials +from .errors import InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected +from .limiter import BudgetLimiter, Priority +from .polymarket_signing import MAX_UINT256, WalletSigner +from .types import ( + Account, Balance, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, + PositionSide, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "azuro" + +DEADLINE_S = 300 +"""How long a signed bet stays valid: the venue's SDK default, five minutes.""" + +ORDER_STATUS = { + "Created": OrderStatus.PENDING, + "Placed": OrderStatus.PENDING, + "Sent": OrderStatus.PENDING, + "Accepted": OrderStatus.CLOSED, + "Rejected": OrderStatus.REJECTED, + "PendingCancel": OrderStatus.PENDING_CANCEL, + "CancelFailed": OrderStatus.CLOSED, + "Canceled": OrderStatus.CANCELED, + "Settled": OrderStatus.CLOSED, +} +"""A bet order's state: `Created` -> `Placed` -> `Sent` -> `Accepted` or +`Rejected`, then `Settled`. `Canceled` is the venue cancelling it (a +cancelled game, a failed relay); `CancelFailed` leaves the bet standing.""" + +PENDING_STATES = ("Created", "Placed", "Sent", "PendingCancel") +FILLED_STATES = ("Accepted", "CancelFailed", "Settled") + +ORDERS_PAGE = 100 +MAX_ORDER_PAGES = 50 + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return Decimal("0") + return Decimal(str(value)) + + +def iso_ms(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def market_of(condition_id: str, outcome_id: str, outcomes: list[str] | None) -> tuple[str, Side]: + """The Synpath market a bet on this outcome is on, and its side there: + on a two-outcome condition the lower outcome is YES (`buy`), the other NO + (`sell`); on a larger one every outcome is its own market's YES. A + condition whose outcomes are unknown is read as a larger one.""" + if outcomes and len(outcomes) == 2: + return ids.qualify(VENUE, condition_id), Side.BUY if str(outcome_id) == outcomes[0] else Side.SELL + return ids.qualify(VENUE, f"{condition_id}-{outcome_id}"), Side.BUY + + +def yes_price(odds: Decimal, side: Side) -> Decimal | None: + """The YES price of a bet at these odds, on its side.""" + if odds <= 1: + return None + paid = Decimal(1) / odds + return paid if side == Side.BUY else Decimal(1) - paid + + +def _leg(row: dict[str, Any]) -> dict[str, Any]: + legs = row.get("conditions") or [] + return legs[0] if legs and isinstance(legs[0], dict) else {} + + +def contracts_of_row(row: dict[str, Any]) -> Decimal: + """What the bet pays if it wins: its stake times its odds.""" + leg = _leg(row) + if leg.get("potentialAmount") not in (None, "") and row.get("betType") != "COMBO": + return D(leg["potentialAmount"]) + return D(row.get("amount")) * D(row.get("odds")) + + +def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None) -> Order: + """One bet order (`GET /bet/orders/{id}`, `GET /bet/orders/bettor/...`), + as an order on the YES leg. A combo is reported on its first leg's market, + marked `info["combo"]`.""" + leg = _leg(row) + market_id, side = market_of(str(leg.get("conditionId") or ""), str(leg.get("outcomeId") or ""), outcomes) + state = str(row.get("state") or "") + status = ORDER_STATUS.get(state, OrderStatus.PENDING) + odds = D(row.get("odds") or leg.get("price")) + quantity = contracts_of_row(row) + filled = quantity if state in FILLED_STATES else Decimal("0") + price = yes_price(odds, side) + return Order( + id=str(row.get("id") or ""), + venue=VENUE, + account=account, + market_id=market_id, + side=side, + type=OrderType.MARKET, + time_in_force=TimeInForce.FOK, + status=status, + price=price, + quantity=quantity, + filled=filled, + remaining=quantity - filled if status in (OrderStatus.PENDING, OrderStatus.PENDING_CANCEL) else Decimal("0"), + average_price=price if filled else None, + cost=D(row.get("amount")), + created_at=iso_ms(row.get("createdAt")), + updated_at=iso_ms(row.get("updatedAt")), + info={**row, "combo": row.get("betType") == "COMBO"}, + ) + + +def fill_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None) -> Fill | None: + """An accepted single bet as one fill: the pool took it whole.""" + if row.get("betType") == "COMBO" or str(row.get("state") or "") not in FILLED_STATES: + return None + order = order_of_row(row, outcomes=outcomes, account=account) + return Fill( + id=str(row.get("txHash") or row.get("betId") or row.get("id") or ""), + order_id=order.id, + venue=VENUE, + account=account, + market_id=order.market_id, + side=order.side, + price=order.price or Decimal("0"), + quantity=order.quantity, + fee=None, + liquidity=Liquidity.TAKER, + timestamp=iso_ms(row.get("createdAt")) or 0, + info=row, + ) + + +def settlement_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None) -> Settlement | None: + """A settled single bet: won, lost, or cancelled (the stake returned).""" + result = row.get("result") + if row.get("betType") == "COMBO" or result in (None, ""): + return None + order = order_of_row(row, outcomes=outcomes, account=account) + cost = D(row.get("amount")) + payout = D(row.get("payout")) if row.get("payout") is not None else (order.quantity if result == "Won" else + cost if result == "Canceled" else Decimal("0")) + return Settlement( + venue=VENUE, account=account, market_id=order.market_id, + held=PositionSide.LONG if order.side == Side.BUY else PositionSide.SHORT, + result=str(result).lower(), won=result == "Won" if result != "Canceled" else None, + quantity=order.quantity, cost=cost, payout=payout, pnl=payout - cost, + timestamp=iso_ms(row.get("settledAt")) or iso_ms(row.get("updatedAt")), + info=row, + ) + + +def positions_of(rows: list[dict[str, Any]], *, outcomes: dict[str, list[str]], account: Account | None = None) -> list[Position]: + """Accepted, unsettled single bets netted per market on the YES leg.""" + nets: dict[str, dict[str, Decimal]] = {} + for row in rows: + if row.get("betType") == "COMBO" or str(row.get("state") or "") not in ("Accepted", "CancelFailed") \ + or row.get("result") not in (None, ""): + continue + leg = _leg(row) + condition_id = str(leg.get("conditionId") or "") + market_id, side = market_of(condition_id, str(leg.get("outcomeId") or ""), outcomes.get(condition_id)) + entry = nets.setdefault(market_id, {"yes": Decimal("0"), "no": Decimal("0"), "cost": Decimal("0")}) + entry["yes" if side == Side.BUY else "no"] += contracts_of_row(row) + entry["cost"] += D(row.get("amount")) + out = [] + for market_id, entry in nets.items(): + net = entry["yes"] - entry["no"] + out.append(Position( + venue=VENUE, account=account, market_id=market_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), inventory_yes=entry["yes"], inventory_no=entry["no"], margin=entry["cost"], + info={"bets": "accepted, unsettled single bets"}, + )) + return out + + +def error_of(exc: ExchangeError) -> ExchangeError: + body = exc.body if isinstance(exc.body, dict) else {} + text = str(body.get("errorMessage") or body.get("message") or body.get("error") or exc) + lower = text.lower() + if "balance" in lower or "allowance" in lower or "insufficient" in lower: + return InsufficientFunds(f"{VENUE}: {text} -- the wallet needs the bet token, and an allowance to the " + "relayer for the stake and its fee (approve())", body=exc.body) + if any(word in lower for word in ("odds", "amount", "limit", "signature", "expired", "nonce")): + return InvalidOrder(f"{VENUE}: {text}") + return OrderRejected(f"{VENUE}: {text}", reason=lower or None, info=body, body=exc.body) + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.type == OrderType.LIMIT and request.time_in_force not in (TimeInForce.IOC, TimeInForce.FOK): + raise InvalidOrder(f"{VENUE}: the pool holds no resting orders; send a market order with a worst price, " + "or a limit that is ioc or fok") + if request.time_in_force in (TimeInForce.GTD, TimeInForce.DAY): + raise InvalidOrder(f"{VENUE}: a bet is taken at once or not at all; there is no {request.time_in_force.value}") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a bet is sent with the worst price you give as its minimum odds") + if request.post_only: + raise InvalidOrder(f"{VENUE}: the pool only takes; there is no post-only bet") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: bets are not netted at the venue; there is no reduce-only bet") + + +def selection_of(request: OrderRequest, outcomes: list[str]) -> tuple[str, str, Decimal]: + """(condition id, outcome id, price paid for the outcome) for a request.""" + condition_id, outcome_id = parse_market_id(ids.native(VENUE, request.market_id)) + price = D(request.price) + if outcome_id is not None: + if len(outcomes) == 2: + raise InvalidOrder(f"{VENUE}: {condition_id} has two outcomes and is one market, {VENUE}:{condition_id}") + if outcomes and outcome_id not in outcomes: + raise InvalidOrder(f"{VENUE}: condition {condition_id} has no outcome {outcome_id}") + if request.side == Side.SELL: + raise InvalidOrder(f"{VENUE}: one outcome of a larger condition has only YES to buy; the pool sells no NO") + return condition_id, outcome_id, price + if len(outcomes) != 2: + raise InvalidOrder(f"{VENUE}: condition {condition_id} has {len(outcomes)} outcomes; name one: " + f"{', '.join(f'{VENUE}:{condition_id}-{o}' for o in outcomes)}") + if request.side == Side.BUY: + return condition_id, outcomes[0], price + return condition_id, outcomes[1], Decimal(1) - price + + +def build_bet( + request: OrderRequest, *, chain: Chain, signer: WalletSigner, outcomes: list[str], relayer_fee: int, + affiliate: str | None = None, nonce: int, now: float | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """`(body, bet)` for one request, signed, before anything is sent.""" + check_request(request) + condition_id, outcome_id, paid = selection_of(request, outcomes) + data = sig.client_data(chain, affiliate=affiliate or signer.address, + expires_at=int(time.time() if now is None else now) + DEADLINE_S, relayer_fee=relayer_fee) + bet = sig.sub_bet(condition_id=condition_id, outcome_id=outcome_id, min_odds=sig.min_odds(paid), + amount=sig.stake(D(request.quantity), paid, chain.decimals), nonce=nonce) + body = sig.wire_bet(chain, bettor=signer.address, data=data, bet=bet, signature=sig.sign_bet(signer, data, bet)) + return body, bet + + +def relayer_fee_of(info: Any) -> int: + """The relayer's fee in base units from `/bet/gas-info`: an object in the + toolkit, a one-element list in the API guide.""" + row = info[0] if isinstance(info, list) and info else info + if not isinstance(row, dict) or row.get("relayerFeeAmount") in (None, ""): + raise ExchangeError(f"{VENUE}: no relayer fee in /bet/gas-info: {info}", body=info) + return int(D(row["relayerFeeAmount"])) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class AzuroTrading(TradingExchange): + """Azuro bets. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.azuro import AzuroTrading + + async with AzuroTrading(require("azuro", load_credentials())) as azuro: + print(await azuro.fetch_balance(), await azuro.fetch_allowance()) + ``` + """ + + id = VENUE + name = "Azuro" + has: dict[str, Capability] = { + "create_order": True, + # One request a bet; not atomic, one result each. + "create_orders": True, + # A bet on chain cannot be cancelled; it can be cashed out. + "cancel_order": False, + "cancel_orders": False, + "cancel_all_orders": False, + "edit_order": False, + "fetch_order": True, + # Bets the relayer has not yet put on chain. + "fetch_open_orders": True, + "fetch_orders": True, + "fetch_my_trades": True, + "fetch_positions": True, + # The bet token in the wallet, read on chain. + "fetch_balance": True, + "fetch_settlements": True, + "fetch_queue_position": False, + # The relayer's flat fee per bet. + "fetch_fee_estimate": True, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: AzuroCredentials, + *, + account_name: str = "default", + catalog: Any = None, + limiter: BudgetLimiter | None = None, + timeout: float = 30.0, + client: Any = None, + ): + import httpx + + self.credentials = credentials + self.chain = chain_of(credentials.chain) + if self.chain.discontinued: + raise BadRequest(f"{VENUE}: the {self.chain.name} deployment is discontinued and takes no bets") + self.signer = WalletSigner(credentials.private_key) + self.account = Account(venue=VENUE, name=account_name) + self.limiter = limiter or BudgetLimiter(read_per_second=4, write_per_second=4) + self.rpc_url = credentials.rpc_url or self.chain.rpc_url + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + self.http = AsyncHttpClient(self.chain.api_url, limiter=None, client=shared, venue=VENUE) + self._own_catalog = catalog is None + if catalog is None: + catalog = Azuro(chain=self.chain) + self.catalog = catalog + """The read adapter: conditions' outcomes, which decide market ids and sides.""" + self._outcomes: dict[str, list[str]] = {} + self._nonce = 0 + + @property + def address(self) -> str: + return self.signer.address + + # -- plumbing ------------------------------------------------------------- + + async def _call( + self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, + ) -> Any: + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + query = {k: v for k, v in (params or {}).items() if v is not None} + return await self.http.request(method, path, params=query or None, json=body) + + async def _rpc(self, method: str, params: list[Any]) -> Any: + raw = await self.http.request("POST", self.rpc_url, json={"jsonrpc": "2.0", "id": 1, "method": method, + "params": params}) + if not isinstance(raw, dict) or raw.get("error") or "result" not in raw: + raise ExchangeError(f"{VENUE}: {self.chain.name} rpc {method}: {raw}", body=raw) + return raw["result"] + + async def outcomes_of(self, condition_ids: list[str]) -> dict[str, list[str]]: + """Each condition's outcome ids, lowest first, read once and kept.""" + wanted = [c for c in dict.fromkeys(condition_ids) if c and c not in self._outcomes] + if wanted: + self._outcomes.update(await asyncio.to_thread(self.catalog.condition_outcomes, wanted)) + return {c: self._outcomes.get(c, []) for c in condition_ids} + + async def _orders_from(self, rows: list[dict[str, Any]]) -> dict[str, list[str]]: + return await self.outcomes_of([str(_leg(r).get("conditionId") or "") for r in rows]) + + def _next_nonce(self) -> int: + """Milliseconds, as the venue's SDK uses, never repeated in this process.""" + self._nonce = max(self._nonce + 1, int(time.time() * 1000)) + return self._nonce + + async def relayer_fee(self) -> int: + """The relayer's fee for one bet now, in the bet token's base units.""" + return relayer_fee_of(await self._call("GET", "/bet/gas-info", params={"environment": self.chain.environment})) + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest) -> Order: + """Sign one bet and hand it to the relayer. Returns it pending; it is + on chain once `fetch_order` says `closed` (accepted) or `rejected`.""" + condition_id, _ = parse_market_id(ids.native(VENUE, request.market_id)) + check_request(request) + outcomes = (await self.outcomes_of([condition_id]))[condition_id] + if not outcomes: + raise MarketNotFound(f"{VENUE}: no condition {condition_id}") + body, bet = build_bet(request, chain=self.chain, signer=self.signer, outcomes=outcomes, + relayer_fee=await self.relayer_fee(), affiliate=self.credentials.affiliate, + nonce=self._next_nonce()) + try: + answer = await self._call("POST", "/bet/orders/ordinar", body=body, kind="write") or {} + except (BadRequest, MarketNotFound) as exc: + raise error_of(exc) from None + state = str(answer.get("state") or "") + if state != "Created" or not answer.get("id"): + raise OrderRejected(f"{VENUE}: bet not created: {answer.get('errorMessage') or state or answer}", + reason=str(answer.get("error") or state or "rejected").lower(), info=answer) + market_id, side = market_of(condition_id, bet["outcomeId"], outcomes) + paid = Decimal(sig.ODDS_SCALE) / Decimal(int(bet["minOdds"])) + quantity = sig.token_amount(bet["amount"], self.chain.decimals) / paid + return Order( + id=str(answer["id"]), client_order_id=request.client_order_id, venue=VENUE, + account=request.account or self.account, market_id=market_id, side=side, + type=request.type, time_in_force=request.time_in_force, status=OrderStatus.PENDING, + price=paid if side == Side.BUY else Decimal(1) - paid, + quantity=quantity.quantize(Decimal("0.000001")), filled=Decimal("0"), + cost=sig.token_amount(bet["amount"], self.chain.decimals), + created_at=int(time.time() * 1000), book=request.book, trader=request.trader, tags=request.tags, + info={"answer": answer, "bet": bet}, + ) + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many bets, one request each, one result each in the order asked for.""" + out: list[Order | Exception] = [] + for request in requests: + try: + out.append(await self.create_order(request)) + except (InvalidOrder, OrderRejected, InsufficientFunds, MarketNotFound, BadRequest) as exc: + out.append(exc) + return out + + async def wait_for_order(self, order_id: str, *, timeout_s: float = 30.0, poll_s: float = 1.0) -> Order: + """Read the order until the relayer has put it on chain or it was + refused, as the venue's SDK does; the last read when time runs out.""" + loop = asyncio.get_running_loop() + stop = loop.time() + timeout_s + while True: + order = await self.fetch_order(order_id) + if order.status != OrderStatus.PENDING or loop.time() >= stop: + return order + await asyncio.sleep(poll_s) + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + raise NotSupported(f"{VENUE}: a bet cannot be cancelled once signed; cash it out with cash_out() where offered") + + async def fetch_order(self, order_id: str) -> Order: + """One bet order by its id (`_`), in any state.""" + try: + row = await self._call("GET", f"/bet/orders/{order_id}") + except MarketNotFound: + raise OrderNotFound(f"{VENUE}: no bet order {order_id}") from None + if not isinstance(row, dict) or not row.get("id"): + raise OrderNotFound(f"{VENUE}: no bet order {order_id}") + outcomes = await self._orders_from([row]) + return order_of_row(row, outcomes=outcomes.get(str(_leg(row).get("conditionId") or "")), account=self.account) + + async def _bettor_rows(self, *, states: list[str] | None = None, result: list[str] | None = None, + offset: int = 0, limit: int | None = None) -> list[dict[str, Any]]: + """This wallet's bet orders, newest first, page by page.""" + rows: list[dict[str, Any]] = [] + for _ in range(MAX_ORDER_PAGES): + size = min(ORDERS_PAGE, (limit - len(rows)) if limit else ORDERS_PAGE) + params: list[tuple[str, Any]] = [("environment", self.chain.environment), ("offset", offset), ("limit", size)] + params += [("states", s) for s in states or []] + [("result", r) for r in result or []] + try: + raw = await self._bettor_page(params) + except MarketNotFound: + return rows + page = [r for r in (raw or {}).get("orders") or [] if isinstance(r, dict)] + rows += page + offset += len(page) + if len(page) < size or (limit and len(rows) >= limit): + break + return rows + + async def _bettor_page(self, params: list[tuple[str, Any]]) -> Any: + await self.limiter.acquire(cost=1, kind="read", priority=Priority.NORMAL) # type: ignore[arg-type] + return await self.http.request("GET", f"/bet/orders/bettor/{self.address}", params=params) + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of this wallet's bets, newest first. `status` is `open` + (not yet on chain) or `closed` (accepted, settled or ended); the cursor + is the venue's offset into the list.""" + if cursor is not None and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + states = list(PENDING_STATES) if status == "open" else None + offset, size = int(cursor or 0), min(limit or 50, ORDERS_PAGE) + rows = await self._bettor_rows(states=states, offset=offset, limit=size) + outcomes = await self._orders_from(rows) + orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account) + for r in rows] + if status == "closed": + orders = [o for o in orders if o.is_terminal] + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + orders = [o for o in orders if o.market_id == wanted] + if since: + orders = [o for o in orders if (o.created_at or 0) >= since] + return Page(orders, next_cursor=str(offset + len(rows)) if len(rows) >= size else None) + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Bets signed and not yet on chain: nothing rests in a pool.""" + rows = await self._bettor_rows(states=list(PENDING_STATES)) + outcomes = await self._orders_from(rows) + orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account) + for r in rows] + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + orders = [o for o in orders if o.market_id == wanted] + return orders + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """This wallet's accepted single bets, one fill each, newest first.""" + if cursor is not None and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + offset, size = int(cursor or 0), min(limit or 50, ORDERS_PAGE) + rows = await self._bettor_rows(states=list(FILLED_STATES), offset=offset, limit=size) + outcomes = await self._orders_from(rows) + fills = [f for f in (fill_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), + account=self.account) for r in rows) if f is not None] + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + fills = [f for f in fills if f.market_id == wanted] + if order_id: + fills = [f for f in fills if f.order_id == order_id] + if since: + fills = [f for f in fills if f.timestamp >= since] + return Page(fills, next_cursor=str(offset + len(rows)) if len(rows) >= size else None) + + async def fetch_settlements( + self, *, market_id: str | None = None, since: int | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Settlement]: + """This wallet's settled single bets: won, lost or cancelled.""" + if cursor is not None and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + offset, size = int(cursor or 0), min(limit or 50, ORDERS_PAGE) + rows = await self._bettor_rows(result=["Won", "Lost", "Canceled"], offset=offset, limit=size) + outcomes = await self._orders_from(rows) + out = [s for s in (settlement_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), + account=self.account) for r in rows) if s is not None] + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + out = [s for s in out if s.market_id == wanted] + if since: + out = [s for s in out if (s.timestamp or 0) >= since] + return Page(out, next_cursor=str(offset + len(rows)) if len(rows) >= size else None) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """The wallet's bet token, read on chain. Accepted bets are held by + the pool, not the wallet, so nothing here is `locked`.""" + raw = await self._rpc("eth_call", [{"to": self.chain.token, "data": sig.balance_calldata(self.address)}, "latest"]) + total = sig.token_amount(int(raw, 16), self.chain.decimals) + return Balance( + venue=VENUE, account=account or self.account, currency=self.chain.symbol, + total=total, available=total, locked=None, buying_power=None, + timestamp=int(time.time() * 1000), info={"address": self.address, "token": self.chain.token, + "chain": self.chain.name}, + ) + + async def fetch_allowance(self) -> Decimal: + """What the relayer may spend of the wallet's bet token, read on chain.""" + raw = await self._rpc("eth_call", [{"to": self.chain.token, "data": sig.allowance_calldata( + self.address, self.chain.relayer)}, "latest"]) + return sig.token_amount(int(raw, 16), self.chain.decimals) + + async def approve(self, amount: Decimal | None = None, *, wait: bool = True, timeout_s: float = 120.0) -> dict[str, Any]: + """Allow the relayer to spend the wallet's bet token: unlimited by + default, as the venue's SDK does, or `amount` tokens. An on-chain + transaction from the wallet, which pays its gas in the chain's coin.""" + value = MAX_UINT256 if amount is None else int(D(amount) * (Decimal(10) ** self.chain.decimals)) + data = sig.approve_calldata(self.chain.relayer, value) + nonce = int(await self._rpc("eth_getTransactionCount", [self.address, "pending"]), 16) + gas_price = int(await self._rpc("eth_gasPrice", []), 16) + priority_fee = int(await self._rpc("eth_maxPriorityFeePerGas", []), 16) + estimate = int(await self._rpc("eth_estimateGas", [{"from": self.address, "to": self.chain.token, "data": data}]), 16) + tx = { + "chainId": self.chain.chain_id, "nonce": nonce, "to": self.chain.token, "value": 0, "data": data, + "gas": int(estimate * 1.2), "maxFeePerGas": gas_price * 2 + priority_fee, + "maxPriorityFeePerGas": priority_fee, "type": 2, + } + tx_hash = await self._rpc("eth_sendRawTransaction", [self.signer.sign_transaction(tx)]) + if not wait: + return {"transaction_hash": tx_hash} + loop = asyncio.get_running_loop() + stop = loop.time() + timeout_s + while True: + receipt = await self._rpc("eth_getTransactionReceipt", [tx_hash]) + if receipt: + if int(receipt.get("status", "0x0"), 16) != 1: + raise OrderRejected(f"{VENUE}: approval {tx_hash} reverted", reason="reverted", info=receipt) + return {"transaction_hash": tx_hash, "receipt": receipt} + if loop.time() >= stop: + raise ExchangeError(f"{VENUE}: approval {tx_hash} unmined after {timeout_s}s; it may yet confirm") + await asyncio.sleep(2.0) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """Accepted, unsettled single bets netted per market on the YES leg.""" + rows = await self._bettor_rows(states=["Accepted", "CancelFailed"]) + rows = [r for r in rows if r.get("result") in (None, "")] + outcomes = await self._orders_from(rows) + positions = positions_of(rows, outcomes=outcomes, account=self.account) + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + positions = [p for p in positions if p.market_id == wanted] + if event_id: + game = ids.native(VENUE, event_id) + positions = [p for p in positions + if game_of_condition(parse_market_id(ids.split(p.market_id)[1])[0]) == game] + return positions + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The relayer's flat fee for one bet, in the bet token: there is no + fee on top of the pool's odds, whose margin is already in them.""" + fee = sig.token_amount(await self.relayer_fee(), self.chain.decimals) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, ids.native(VENUE, market_id)), side=side, + price=D(price), quantity=D(quantity), taker_fee=fee, maker_fee=None, + currency=self.chain.symbol, info={"charged": "per bet, to the relayer, for the transaction"}, + ) + + # -- cash-out ------------------------------------------------------------- + + async def fetch_cashout_quote(self, bet_id: str | int) -> dict[str, Any] | None: + """What the venue offers now to close one accepted bet early + (`/cashout/get-calculation`), by the bet's on-chain id (`Order.info["betId"]`); + `None` when it offers nothing.""" + try: + raw = await self._call("POST", "/cashout/get-calculation", body={ + "environment": self.chain.environment, "owner": self.address, "betId": str(bet_id), + }) + except MarketNotFound: + return None + return raw if isinstance(raw, dict) and raw.get("calculationId") else None + + async def cash_out(self, bet_id: str | int, *, quote: dict[str, Any] | None = None) -> dict[str, Any]: + """Close one accepted bet at the venue's cash-out offer: the offer is + read (or given), signed by the wallet with its odds as the minimum, + and sent (`/cashout/create`). Returns the cash-out order; read it on + with `fetch_cashout`.""" + quote = quote or await self.fetch_cashout_quote(bet_id) + if not quote: + raise OrderRejected(f"{VENUE}: no cash-out offered on bet {bet_id}", reason="no_cashout") + message = sig.cashout_message(self.chain, bet_id=bet_id, cashout_odds=str(quote["cashoutOdds"]), + expires_at=int(quote["expiredAt"])) + body = sig.wire_cashout(self.chain, calculation_id=str(quote["calculationId"]), attention=message["attention"], + signature=sig.sign_cashout(self.signer, self.chain, message)) + try: + answer = await self._call("POST", "/cashout/create", body=body, kind="write") or {} + except (BadRequest, MarketNotFound) as exc: + raise error_of(exc) from None + if str(answer.get("state") or "").upper() == "REJECTED": + raise OrderRejected(f"{VENUE}: cash-out refused: {answer.get('errorMessage') or answer}", + reason="cashout_rejected", info=answer) + return answer + + async def fetch_cashout(self, cashout_id: str) -> dict[str, Any]: + """One cash-out order: its state and, once done, its transaction.""" + try: + return await self._call("GET", f"/cashout/{cashout_id}") or {} + except MarketNotFound: + raise OrderNotFound(f"{VENUE}: no cash-out {cashout_id}") from None + + async def close(self) -> None: + await self.http.close() + if self._own_catalog: + self.catalog.close() + + +__all__ = ["AzuroTrading", "order_of_row", "fill_of_row", "settlement_of_row", "positions_of", "build_bet"] diff --git a/synpath/trading/azuro_signing.py b/synpath/trading/azuro_signing.py new file mode 100644 index 0000000..0ccd8b5 --- /dev/null +++ b/synpath/trading/azuro_signing.py @@ -0,0 +1,316 @@ +"""Azuro bet and cash-out signing, and the amounts in them, with no network in sight. + +Built to `@azuro-org/toolkit` 7.2.0 (`getBetTypedData`, `createBet`, +`getCashoutTypedData`, `createCashout`), and checked against digests and +signatures that toolkit and viem produce (`tests/samples/azuro_signing_vectors.json`). + +**A bet** is the EIP-712 message `ClientBetData`: the `ClientData` (a consent +text, the affiliate, the pool's core contract, an expiry, the chain id, the +relayer's fee and three sponsorship flags) and a one-element `SubBet[]` (the +condition, the outcome, the minimum odds, the stake and a nonce), under the +domain `Live Betting` version `1.0.0`, the chain's id, and the core contract as +the verifying contract. The bettor's wallet signs it; the relayer places the +bet on chain and takes its fee from the bettor's tokens, which needs the +bettor's allowance to the relayer for the stake plus the fee. + +**Odds** are fixed point with 12 decimals (1.5 is `1_500_000_000_000`). A bet +names its worst acceptable odds; the YES price given is turned into them as +`1 / price`, rounded up, so a bet never pays more per contract than asked. + +**A cash-out** is `CashOutOrder` (a consent text, the chain id, one +`CashOutItem` -- the bet's id, the core contract, the minimum odds -- and an +expiry) under the domain `Cash Out` version `1.0.0`, the chain's id and the +cash-out contract. +""" +from __future__ import annotations + +from decimal import ROUND_CEILING, ROUND_DOWN, Decimal +from typing import Any + +from ..azuro import Chain +from .errors import InvalidOrder +from .polymarket_signing import WalletSigner, _eth + +ODDS_DECIMALS = 12 +ODDS_SCALE = 10 ** ODDS_DECIMALS + +BET_DOMAIN_NAME = "Live Betting" +BET_DOMAIN_VERSION = "1.0.0" +CASHOUT_DOMAIN_NAME = "Cash Out" +CASHOUT_DOMAIN_VERSION = "1.0.0" + +BET_ATTENTION = "By signing this transaction, I agree to place a bet for an event on Azuro Protocol" +CASHOUT_ATTENTION = "By signing this transaction, I agree to cash out on Azuro" +"""The consent texts the venue's SDK signs by default.""" + +DOMAIN_FIELDS = [ + {"name": "name", "type": "string"}, {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}, +] +CLIENT_DATA_FIELDS = [ + {"name": "attention", "type": "string"}, + {"name": "affiliate", "type": "address"}, + {"name": "core", "type": "address"}, + {"name": "expiresAt", "type": "uint256"}, + {"name": "chainId", "type": "uint256"}, + {"name": "relayerFeeAmount", "type": "uint256"}, + {"name": "isFeeSponsored", "type": "bool"}, + {"name": "isBetSponsored", "type": "bool"}, + {"name": "isSponsoredBetReturnable", "type": "bool"}, +] +BET_TYPES: dict[str, list[dict[str, str]]] = { + "ClientBetData": [{"name": "clientData", "type": "ClientData"}, {"name": "bets", "type": "SubBet[]"}], + "ClientData": CLIENT_DATA_FIELDS, + "SubBet": [ + {"name": "conditionId", "type": "uint256"}, + {"name": "outcomeId", "type": "uint128"}, + {"name": "minOdds", "type": "uint64"}, + {"name": "amount", "type": "uint128"}, + {"name": "nonce", "type": "uint256"}, + ], +} +CASHOUT_TYPES: dict[str, list[dict[str, str]]] = { + "CashOutItem": [ + {"name": "betId", "type": "uint256"}, + {"name": "bettingContract", "type": "address"}, + {"name": "minOdds", "type": "uint64"}, + ], + "CashOutOrder": [ + {"name": "attention", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "items", "type": "CashOutItem[]"}, + {"name": "expiresAt", "type": "uint64"}, + ], +} + + +# --------------------------------------------------------------------------- +# Amounts +# --------------------------------------------------------------------------- + +def odds_raw(odds: Decimal | str | float) -> int: + """Decimal odds as the protocol's 12-decimal fixed point, exactly.""" + return int((Decimal(str(odds)) * ODDS_SCALE).to_integral_value(rounding=ROUND_DOWN)) + + +def odds_of_raw(raw: int | str) -> Decimal: + return Decimal(int(raw)) / ODDS_SCALE + + +def min_odds(price: Decimal) -> int: + """The worst odds a bet at this price accepts: `1 / price`, rounded up + to the protocol's 12 decimals, so the price paid is never above `price`.""" + price = Decimal(str(price)) + if not Decimal("0") < price < Decimal("1"): + raise InvalidOrder(f"azuro: price {price} must be strictly between 0 and 1") + return int((Decimal(ODDS_SCALE) / price).to_integral_value(rounding=ROUND_CEILING)) + + +def stake(contracts: Decimal, price: Decimal, decimals: int) -> int: + """The stake, in the bet token's base units, that buys `contracts` at + `price`: `contracts * price`, rounded down. At better odds than `price` + the same stake returns more contracts.""" + amount = int((Decimal(str(contracts)) * Decimal(str(price)) * (Decimal(10) ** decimals)) + .to_integral_value(rounding=ROUND_DOWN)) + if amount <= 0: + raise InvalidOrder(f"azuro: {contracts} contracts at {price} stakes nothing") + return amount + + +def token_amount(raw: int | str, decimals: int) -> Decimal: + return Decimal(int(raw)) / (Decimal(10) ** decimals) + + +# --------------------------------------------------------------------------- +# EIP-712 +# --------------------------------------------------------------------------- + +def _encode_type(primary: str, types: dict[str, list[dict[str, str]]]) -> str: + """`Primary(fields)` followed by every referenced struct, sorted by name.""" + found: set[str] = set() + + def walk(name: str) -> None: + if name in found or name not in types: + return + found.add(name) + for field in types[name]: + walk(field["type"].split("[")[0]) + + walk(primary) + order = [primary] + sorted(found - {primary}) + return "".join(f"{name}({','.join(f['type'] + ' ' + f['name'] for f in types[name])})" for name in order) + + +def _encode_value(kind: str, value: Any, types: dict[str, list[dict[str, str]]]) -> bytes: + abi_encode, _, _, keccak, to_checksum_address = _eth() + if kind.endswith("]"): + inner = kind[:kind.rindex("[")] + return keccak(b"".join(_encode_value(inner, item, types) for item in value)) + if kind in types: + return hash_struct(kind, types, value) + if kind == "string": + return keccak(text=str(value)) + if kind == "bytes": + return keccak(bytes.fromhex(str(value).removeprefix("0x")) if isinstance(value, str) else bytes(value)) + if kind == "address": + return abi_encode(["address"], [to_checksum_address(value)]) + if kind == "bool": + return abi_encode(["bool"], [bool(value)]) + return abi_encode([kind], [int(value)]) + + +def hash_struct(primary: str, types: dict[str, list[dict[str, str]]], data: dict[str, Any]) -> bytes: + _, _, _, keccak, _ = _eth() + type_hash = keccak(text=_encode_type(primary, types)) + return keccak(type_hash + b"".join(_encode_value(f["type"], data[f["name"]], types) for f in types[primary])) + + +def domain_separator(name: str, version: str, chain_id: int, contract: str) -> bytes: + return hash_struct("EIP712Domain", {"EIP712Domain": DOMAIN_FIELDS}, + {"name": name, "version": version, "chainId": chain_id, "verifyingContract": contract}) + + +def digest(domain: dict[str, Any], primary: str, types: dict[str, list[dict[str, str]]], message: dict[str, Any]) -> bytes: + _, _, _, keccak, _ = _eth() + separator = domain_separator(domain["name"], domain["version"], int(domain["chainId"]), domain["verifyingContract"]) + return keccak(b"\x19\x01" + separator + hash_struct(primary, types, message)) + + +# --------------------------------------------------------------------------- +# Bets +# --------------------------------------------------------------------------- + +def client_data( + chain: Chain, *, affiliate: str, expires_at: int, relayer_fee: int | str, attention: str = BET_ATTENTION, +) -> dict[str, Any]: + """The `ClientData` of a bet, in the shape the API takes it back: + numbers for the expiry and the chain id, a decimal string for the fee.""" + _, _, _, _, to_checksum_address = _eth() + return { + "attention": attention, + "affiliate": to_checksum_address(affiliate), + "core": to_checksum_address(chain.core), + "expiresAt": int(expires_at), + "chainId": chain.chain_id, + "relayerFeeAmount": str(int(relayer_fee)), + "isBetSponsored": False, + "isFeeSponsored": False, + "isSponsoredBetReturnable": False, + } + + +def sub_bet(*, condition_id: str, outcome_id: str | int, min_odds: int, amount: int, nonce: int) -> dict[str, Any]: + return {"conditionId": str(condition_id), "outcomeId": str(outcome_id), "minOdds": str(int(min_odds)), + "amount": str(int(amount)), "nonce": str(int(nonce))} + + +def bet_domain(data: dict[str, Any]) -> dict[str, Any]: + return {"name": BET_DOMAIN_NAME, "version": BET_DOMAIN_VERSION, "chainId": int(data["chainId"]), + "verifyingContract": data["core"]} + + +def bet_message(data: dict[str, Any], bet: dict[str, Any]) -> dict[str, Any]: + return { + "clientData": { + **{k: data[k] for k in ("attention", "affiliate", "core", "isFeeSponsored", "isBetSponsored", + "isSponsoredBetReturnable")}, + "expiresAt": int(data["expiresAt"]), "chainId": int(data["chainId"]), + "relayerFeeAmount": int(data["relayerFeeAmount"]), + }, + "bets": [{k: int(bet[k]) for k in ("conditionId", "outcomeId", "minOdds", "amount", "nonce")}], + } + + +def bet_typed_data(data: dict[str, Any], bet: dict[str, Any]) -> dict[str, Any]: + """The bet as EIP-712 typed data, for wallets that sign that.""" + return { + "primaryType": "ClientBetData", + "types": {"EIP712Domain": DOMAIN_FIELDS, **BET_TYPES}, + "domain": bet_domain(data), + "message": bet_message(data, bet), + } + + +def bet_digest(data: dict[str, Any], bet: dict[str, Any]) -> bytes: + return digest(bet_domain(data), "ClientBetData", BET_TYPES, bet_message(data, bet)) + + +def sign_bet(signer: WalletSigner, data: dict[str, Any], bet: dict[str, Any]) -> str: + """The bettor's signature over the bet, `0x`-hex.""" + return "0x" + bytes(signer.sign_digest(bet_digest(data, bet))).hex() + + +def wire_bet(chain: Chain, *, bettor: str, data: dict[str, Any], bet: dict[str, Any], signature: str) -> dict[str, Any]: + """The signed bet as `POST /bet/orders/ordinar` takes it.""" + return { + "environment": chain.environment, + "bettor": bettor.lower(), + "betOwner": bettor.lower(), + "clientBetData": { + "clientData": data, + "bet": {**bet, "outcomeId": int(bet["outcomeId"])}, + }, + "bettorSignature": signature, + } + + +# --------------------------------------------------------------------------- +# Cash-out +# --------------------------------------------------------------------------- + +def cashout_domain(chain: Chain) -> dict[str, Any]: + return {"name": CASHOUT_DOMAIN_NAME, "version": CASHOUT_DOMAIN_VERSION, "chainId": chain.chain_id, + "verifyingContract": chain.cashout} + + +def cashout_message(chain: Chain, *, bet_id: int | str, cashout_odds: str | Decimal, expires_at: int, + attention: str = CASHOUT_ATTENTION) -> dict[str, Any]: + return { + "attention": attention, + "chainId": chain.chain_id, + "items": [{"betId": int(bet_id), "bettingContract": chain.core, "minOdds": odds_raw(cashout_odds)}], + "expiresAt": int(expires_at), + } + + +def cashout_typed_data(chain: Chain, message: dict[str, Any]) -> dict[str, Any]: + return {"primaryType": "CashOutOrder", "types": {"EIP712Domain": DOMAIN_FIELDS, **CASHOUT_TYPES}, + "domain": cashout_domain(chain), "message": message} + + +def cashout_digest(chain: Chain, message: dict[str, Any]) -> bytes: + return digest(cashout_domain(chain), "CashOutOrder", CASHOUT_TYPES, message) + + +def sign_cashout(signer: WalletSigner, chain: Chain, message: dict[str, Any]) -> str: + return "0x" + bytes(signer.sign_digest(cashout_digest(chain, message))).hex() + + +def wire_cashout(chain: Chain, *, calculation_id: str, attention: str, signature: str) -> dict[str, Any]: + """The signed cash-out as `POST /cashout/create` takes it.""" + return { + "calculationId": calculation_id, + "signature": { + "verifyingContract": chain.cashout, "bettingContract": chain.core, "attention": attention, + "chainId": chain.chain_id, "ownerSignature": signature, + }, + } + + +# --------------------------------------------------------------------------- +# Wallet calls +# --------------------------------------------------------------------------- + +def approve_calldata(spender: str, amount: int) -> str: + abi_encode, _, _, keccak, _ = _eth() + return "0x" + (keccak(text="approve(address,uint256)")[:4] + abi_encode(["address", "uint256"], [spender, amount])).hex() + + +def allowance_calldata(owner: str, spender: str) -> str: + abi_encode, _, _, keccak, _ = _eth() + return "0x" + (keccak(text="allowance(address,address)")[:4] + abi_encode(["address", "address"], [owner, spender])).hex() + + +def balance_calldata(owner: str) -> str: + abi_encode, _, _, keccak, _ = _eth() + return "0x" + (keccak(text="balanceOf(address)")[:4] + abi_encode(["address"], [owner])).hex() diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..197d739 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -45,6 +45,9 @@ (certificate login) and BETFAIR_JURISDICTION Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market (market data) data needs no login; one gives undelayed quotes + Azuro AZURO_PRIVATE_KEY, and optionally AZURO_CHAIN (polygon, + the default, base, polygon_amoy, base_sepolia), + AZURO_AFFILIATE, AZURO_RPC_URL """ from __future__ import annotations @@ -357,10 +360,40 @@ def secrets(self) -> list[str]: return [self.password] +@dataclass(frozen=True, repr=False) +class AzuroCredentials: + """Azuro: the wallet that signs every bet (EIP-712) and holds the bet + token, and the chain whose pool it bets with. Market data needs nothing.""" + + private_key: str = field(repr=False) + """The wallet's private key, hex. Signs every bet and cash-out; the + relayer places them on chain and is paid from this wallet's tokens.""" + chain: str = "polygon" + """The deployment bet with: `polygon` (USDT), `base` (WETH), or the + testnets `polygon_amoy` and `base_sepolia`.""" + affiliate: str | None = None + """The affiliate address every bet names; the wallet's own when unset.""" + rpc_url: str | None = None + """A JSON-RPC endpoint on the chain, for the balance, the allowance and + the approval; the chain's public endpoint when unset.""" + _public = ("chain", "affiliate", "rpc_url") + __repr__ = _redacted_repr + + @property + def address(self) -> str: + from .polymarket_signing import WalletSigner + + return WalletSigner(self.private_key).address + + @property + def secrets(self) -> list[str]: + return [self.private_key] + + Credentials = ( KalshiCredentials | PolymarketCredentials | PolymarketUSCredentials | PolymarketUSExchangeCredentials | OpinionCredentials | HyperliquidCredentials | PredictFunCredentials | LimitlessCredentials | SxBetCredentials - | BetfairCredentials | SmarketsCredentials + | BetfairCredentials | SmarketsCredentials | AzuroCredentials ) ENV_NAMES: dict[str, tuple[str, ...]] = { @@ -389,6 +422,7 @@ def secrets(self) -> list[str]: "BETFAIR_JURISDICTION", ), "smarkets": ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"), + "azuro": ("AZURO_PRIVATE_KEY", "AZURO_CHAIN", "AZURO_AFFILIATE", "AZURO_RPC_URL"), } """What each venue reads. The first entry is the one whose absence means "not configured"; the rest are optional or have defaults.""" @@ -685,6 +719,28 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: return SmarketsCredentials(username=username, password=password) +AZURO_CHAINS = ("polygon", "base", "polygon_amoy", "base_sepolia") +"""The deployments that take bets; Gnosis and Chiliz are discontinued.""" + + +def load_azuro(env: Mapping[str, str]) -> AzuroCredentials | None: + key = env.get("AZURO_PRIVATE_KEY") + if not key: + return None + if not re.match(r"^(0x)?[0-9a-fA-F]{64}$", key): + raise CredentialsMissing("AZURO_PRIVATE_KEY must be 64 hex characters, optionally starting with 0x") + chain = (env.get("AZURO_CHAIN") or "polygon").strip().lower().replace("-", "_") + if chain not in AZURO_CHAINS: + raise CredentialsMissing(f"AZURO_CHAIN must be one of {', '.join(AZURO_CHAINS)} (Gnosis and Chiliz are discontinued)") + affiliate = env.get("AZURO_AFFILIATE") or None + if affiliate and not re.match(r"^0x[0-9a-fA-F]{40}$", affiliate): + raise CredentialsMissing(f"AZURO_AFFILIATE must be a 0x address (40 hex characters); got {_shape_of(affiliate)}") + return AzuroCredentials( + private_key=key if key.startswith("0x") else "0x" + key, chain=chain, affiliate=affiliate, + rpc_url=env.get("AZURO_RPC_URL") or None, + ) + + LOADERS = { "kalshi": load_kalshi, "polymarket": load_polymarket, @@ -697,6 +753,7 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: "sx_bet": load_sx_bet, "betfair": load_betfair, "smarkets": load_smarkets, + "azuro": load_azuro, } diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..0405198 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -278,6 +278,19 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} +def ask_azuro(p: Prompter) -> dict[str, str]: + p.say("\nAzuro: market data is public and needs nothing; this is for betting. It needs the private key") + p.say("(64 hex characters) of the wallet that signs each bet and holds the bet token: USDT on Polygon, WETH") + p.say("on Base. The wallet also approves Azuro's relayer to spend the token once, on chain (approve()).") + key = p.value("Private key (64 hex characters, typed hidden)", secret=True, check=_private_key) + chain = p.value("Chain (polygon, base, polygon_amoy, base_sepolia)", default="polygon", + check=_choice("polygon", "base", "polygon_amoy", "base_sepolia")) + values = {"AZURO_PRIVATE_KEY": _with_0x(key)} + if chain != "polygon": + values["AZURO_CHAIN"] = chain + return values + + VENUES: list[tuple[str, tuple[str, ...], Callable[[Prompter], dict[str, str]]]] = [ ("Kalshi", ("KALSHI_KEY_ID",), ask_kalshi), ("Polymarket", ("POLYMARKET_PRIVATE_KEY",), ask_polymarket), @@ -289,6 +302,7 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), + ("Azuro", ("AZURO_PRIVATE_KEY",), ask_azuro), ] """Each venue, the settings any of which means it is set up already (an API key alone is, for market data), and the questions.""" diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 509a8fd..982ac44 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -66,6 +66,13 @@ # £0.05. "smarkets": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.0001"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("0.05")}, + # A bet's price is a worst price, turned into minimum odds of `1 / price` + # at 12 decimals, so any price in (0, 1) is accepted: the tick here is + # only the grid prices are quoted on. The stake is in the bet token (USDT + # to six decimals, WETH to eighteen), so contracts take any size to a + # millionth; the pool's own minimum bet is read per selection. + "azuro": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.000001"), "quantity_step": Decimal("0.000001"), + "whole": False}, } diff --git a/synpath/types.py b/synpath/types.py index 3233e1d..ee7719b 100644 --- a/synpath/types.py +++ b/synpath/types.py @@ -597,6 +597,11 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo Expert Fee out. Smarkets charges the same way (2% on its standard tier, no Expert Fee), on net winnings per Smarkets market. + * `pool_margin` (Azuro) — nothing: the pool's margin is inside the + odds it quotes, so the price is already the whole cost. A bet sent + through Azuro's relayer pays it a flat fee per bet, in the bet + token, which is not a function of price or size and is left out + here (`AzuroTrading.fetch_fee_estimate` reads it). """ rate = self.taker_rate if taker else self.maker_rate if self.fee_type in KALSHI_MAKER_SHARE and self.multiplier is not None: @@ -620,6 +625,8 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo return round(limitless_buy_rate(price) * rate / LIMITLESS_FEE_CEILING * price * contracts, 6) if self.fee_type in ("profit_on_win", "net_market_winnings") and rate is not None: return round(rate * (1 - price) * contracts, 6) + if self.fee_type == "pool_margin": + return 0.0 if self.fee_type == "quadratic_theta" and rate is not None: power = 1.0 if self.exponent is None else self.exponent return round(rate * contracts * (price * (1 - price)) ** power, 6) diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index 6260aee..20ea70d 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -56,6 +56,9 @@ def __getattr__(name: str): if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream + if name == "AzuroMarketStream": + from .azuro import AzuroMarketStream + return AzuroMarketStream if name in ("HyperliquidMarketStream", "HyperliquidUserStream"): from . import hyperliquid return getattr(hyperliquid, name) @@ -75,5 +78,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "AzuroMarketStream", ] diff --git a/synpath/ws/azuro.py b/synpath/ws/azuro.py new file mode 100644 index 0000000..7ec535c --- /dev/null +++ b/synpath/ws/azuro.py @@ -0,0 +1,263 @@ +"""Azuro's feed WebSocket: conditions' odds and states as quotes, books and status changes. + +One endpoint per network, `wss://streams.onchainfeed.org/v1/streams/feed` +(`dev-streams` for the testnets), speaking plain JSON: the client sends +`{"event": "SubscribeConditions", "data": {"conditionIds": [...], "environment": ...}}` +(and `UnsubscribeConditions`), and the feed answers with a `ConditionUpdated` +message whenever a condition's odds or state change, carrying every outcome's +`currentOdds`. The environment names the chain's pool, as on REST. + +**Quotes.** Each update is the pool's quote on every market of the condition, +read as `Azuro` reads it: buying YES at `1 / odds` of the YES outcome and, on a +two-outcome market, selling at one minus the price of the other. + +**Books.** A pool has no book; `watch_order_book` keeps the quote as a one-level +book whose sizes are the pool's limits on each outcome, read over REST +(`Azuro.fetch_order_book`) when the market is first watched and after every +reconnect. Updates move the prices; the sizes stay as read. + +**Status.** A condition stopping, resuming, resolving or being cancelled +arrives on the same subscription as its odds. +""" +from __future__ import annotations + +import asyncio +from decimal import Decimal +from typing import Any + +from .. import ids +from ..azuro import chain_of, outcome_odds, outcome_order, parse_market_id, quote_of, winner_of +from ..base import Capability +from .base import BookEvent, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream + +VENUE = "azuro" + +STATES = {"Active": "open", "Stopped": "paused", "Resolved": "settled", "Canceled": "settled"} +"""A condition's state as a market state.""" + + +def _dec(value: float | None) -> Decimal | None: + return None if value is None else Decimal(str(value)) + + +class AzuroMarketStream(Stream): + """Odds, one-level books and status changes for Azuro markets. No key needed. + + ```python + async with AzuroMarketStream(chain="polygon") as stream: + await stream.watch_ticker(["azuro:600610060000000000943019940000000000001802657415"]) + async for event in stream: + ... + ``` + """ + + venue = VENUE + name = "market" + has: dict[str, Capability] = { + # The quote as one level a side, sized by the pool's limits. + "watch_order_book": "partial", + "watch_ticker": True, + # Bets reach the tape through the subgraph, not this feed. + "watch_trades": False, + "watch_market_status": True, + } + + def __init__(self, *, chain: Any = "polygon", catalog: Any = None, url: str | None = None, **kwargs: Any): + self.chain = chain_of(chain) + super().__init__(url or f"{self.chain.socket_url}/feed", **kwargs) + self.catalog = catalog + """An `Azuro` for the books' limits; made when first needed.""" + self.markets: dict[str, tuple[str, str | None]] = {} + """Native market id -> (condition id, outcome id or `None`), for every market watched.""" + self.books: dict[str, LocalBook] = {} + self.sizes: dict[str, dict[str, Decimal | None]] = {} + self.tickers: set[str] = set() + self.statuses: set[str] = set() + self.conditions: dict[str, dict[str, Any]] = {} + """Condition id -> its last state and outcomes, as the feed sent them.""" + self.subscribed: set[str] = set() + + # -- intents -------------------------------------------------------------- + + def _add(self, market_ids: list[str]) -> list[str]: + natives = [] + for market_id in market_ids: + native = ids.native(VENUE, market_id) + self.markets[native] = parse_market_id(native) + natives.append(native) + return natives + + async def _subscribe(self) -> None: + wanted = sorted({c for c, _ in self.markets.values()} - self.subscribed) + if wanted and await self.send({"event": "SubscribeConditions", + "data": {"conditionIds": wanted, "environment": self.chain.environment}}): + self.subscribed.update(wanted) + + async def watch_ticker(self, market_ids: list[str]) -> None: + """The pool's quote on every change of odds.""" + self.tickers.update(self._add(market_ids)) + await self._subscribe() + + async def watch_market_status(self, market_ids: list[str]) -> None: + """The condition stopping, resuming, resolving or being cancelled.""" + self.statuses.update(self._add(market_ids)) + await self._subscribe() + + async def watch_order_book(self, market_ids: list[str]) -> None: + """The quote as a one-level book, sized by the pool's limits (read + over REST now), its prices moved by every update.""" + natives = self._add(market_ids) + for native in natives: + self.books.setdefault(native, LocalBook()) + await self._subscribe() + await self._seed(natives) + + async def unwatch(self, market_ids: list[str]) -> None: + for market_id in market_ids: + native = ids.native(VENUE, market_id) + self.markets.pop(native, None) + self.books.pop(native, None) + self.sizes.pop(native, None) + self.tickers.discard(native) + self.statuses.discard(native) + still = {c for c, _ in self.markets.values()} + dropped = sorted(self.subscribed - still) + if dropped: + self.subscribed.difference_update(dropped) + await self.send({"event": "UnsubscribeConditions", + "data": {"conditionIds": dropped, "environment": self.chain.environment}}) + + def book(self, market_id: str, side: str = "yes") -> LocalBook | None: + book = self.books.get(ids.native(VENUE, market_id)) + if book is None: + return None + return book if side != "no" else book.mirrored() + + # -- connection ----------------------------------------------------------- + + async def on_connect(self) -> None: + self.subscribed.clear() + await self._subscribe() + if self.books: + self._later(self._seed(list(self.books))) + + def on_disconnect(self) -> None: + self.subscribed.clear() + for book in self.books.values(): + book.invalidate() + + def _later(self, coroutine: Any) -> None: + try: + asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: # pragma: no cover - no loop + coroutine.close() + + def _catalog(self) -> Any: + if self.catalog is None: + from ..azuro import Azuro + + self.catalog = Azuro(chain=self.chain) + return self.catalog + + async def _seed(self, natives: list[str]) -> None: + """Each book's limits and quote from REST, applied as a snapshot.""" + for native in natives: + try: + book = await asyncio.to_thread(self._catalog().fetch_order_book, native) + except Exception as exc: + self.status("error", f"could not read the pool's limits for {native}: {type(exc).__name__}: {exc}", + key=ids.qualify(VENUE, native)) + continue + local = self.books.get(native) + if local is None: + continue + ask = book.asks[0] if book.asks else None + bid = book.bids[0] if book.bids else None + self.sizes[native] = {"ask": _dec(ask.size) if ask else None, "bid": _dec(bid.size) if bid else None} + recovering = not local.ready and local.timestamp is not None + local.replace([(_dec(bid.price), _dec(bid.size))] if bid else [], # type: ignore[list-item] + [(_dec(ask.price), _dec(ask.size))] if ask else []) # type: ignore[list-item] + local.timestamp = 0 + if recovering: + self.status("resynced", f"book {ids.qualify(VENUE, native)}", key=ids.qualify(VENUE, native)) + self.emit(self._book_event(native, local, info={"seeded": True, "limits": book.info.get("limits")})) + + # -- messages ------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + event = message.get("event") + data = message.get("data") + if event == "SubscribedToConditions": + self.status("subscribed", "conditions") + return [] + if event == "ConditionUpdated" and isinstance(data, dict): + return self.on_condition(data) + return [] + + def on_condition(self, data: dict[str, Any]) -> list[Event]: + condition_id = str(data.get("conditionId") or data.get("id") or "") + if not condition_id: + return [] + previous = self.conditions.get(condition_id) or {} + merged = {**previous, **{k: v for k, v in data.items() if k != "outcomes"}, "conditionId": condition_id} + outcomes = {str(o.get("outcomeId")): dict(o) for o in previous.get("outcomes") or []} + for row in data.get("outcomes") or []: + if isinstance(row, dict) and row.get("outcomeId") is not None: + key = str(row["outcomeId"]) + outcomes[key] = {**outcomes.get(key, {}), **row, "outcomeId": key} + merged["outcomes"] = list(outcomes.values()) + self.conditions[condition_id] = merged + events: list[Event] = [] + changed = previous.get("state") != merged.get("state") and previous + for native, (cid, outcome_id) in self.markets.items(): + if cid != condition_id: + continue + events += self._market_events(native, merged, outcome_id, state_changed=bool(changed) or not previous) + return events + + def _sides(self, condition: dict[str, Any], outcome_id: str | None) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + ordered = outcome_order(condition) + if outcome_id is None: + return (ordered[0], ordered[1]) if len(ordered) == 2 else (None, None) + return next((o for o in ordered if str(o["outcomeId"]) == outcome_id), None), None + + def _market_events(self, native: str, condition: dict[str, Any], outcome_id: str | None, *, + state_changed: bool) -> list[Event]: + yes, no = self._sides(condition, outcome_id) + if yes is None: + return [] + market_id = ids.qualify(VENUE, native) + yes_quote, _ = quote_of(condition, yes, no) + events: list[Event] = [] + info = {"condition_id": condition["conditionId"], "state": condition.get("state"), + "odds": {"yes": outcome_odds(yes), "no": outcome_odds(no) if no else None}} + if native in self.tickers: + events.append(QuoteEvent(venue=VENUE, market_id=market_id, side="yes", bid=_dec(yes_quote.bid), + ask=_dec(yes_quote.ask), info=info)) + book = self.books.get(native) + sizes = self.sizes.get(native) + if book is not None and sizes is not None: + ask, bid = _dec(yes_quote.ask), _dec(yes_quote.bid) + book.replace([(bid, sizes["bid"])] if bid is not None and sizes["bid"] else [], + [(ask, sizes["ask"])] if ask is not None and sizes["ask"] else []) + events.append(self._book_event(native, book, info=info)) + if native in self.statuses and state_changed and condition.get("state") in STATES: + no_id = str(no["outcomeId"]) if no else None + winner = winner_of(condition, str(yes["outcomeId"]), no_id) + events.append(MarketStatusEvent( + venue=VENUE, market_id=market_id, state=STATES[str(condition["state"])], # type: ignore[arg-type] + native=str(condition["state"]), result=winner, info=info, + )) + return events + + def _book_event(self, native: str, book: Any, *, info: dict[str, Any]) -> BookEvent: + bids, asks = book.levels() + return BookEvent( + venue=VENUE, market_id=ids.qualify(VENUE, native), side="yes", kind="snapshot", bids=bids, asks=asks, + best_bid=book.best_bid, best_ask=book.best_ask, info=info, + ) + + +__all__ = ["AzuroMarketStream"] diff --git a/tests/samples/azuro_bets.json b/tests/samples/azuro_bets.json new file mode 100644 index 0000000..55a2da9 --- /dev/null +++ b/tests/samples/azuro_bets.json @@ -0,0 +1,54 @@ +{ + "data": { + "v3Bets": [ + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472063", + "betId": "472063", + "amount": "11", + "odds": "2.6", + "potentialPayout": "28.6", + "status": "Resolved", + "result": "Lost", + "type": "Ordinar", + "createdBlockTimestamp": "1792688426", + "createdTxHash": "0x59b29b0d343ce1351fbb99ddd1e8069d5caa24644a46ea45ff4a3333023b027f", + "selections": [ + { + "odds": "2.6", + "outcome": { + "outcomeId": "2", + "condition": { + "conditionId": "600610060000000000028666020000000000001624778125", + "gameId": "1006000000000002866602" + } + } + } + ] + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472062", + "betId": "472062", + "amount": "11", + "odds": "2.39", + "potentialPayout": "26.29", + "status": "Resolved", + "result": "Lost", + "type": "Ordinar", + "createdBlockTimestamp": "1792688402", + "createdTxHash": "0xc0f78f338f5089ba0c688a1e77319acf9a4c05b853e46a03077301c46770ef3d", + "selections": [ + { + "odds": "2.39", + "outcome": { + "outcomeId": "1", + "condition": { + "conditionId": "600610060000000000028666020000000000001624778127", + "gameId": "1006000000000002866602" + } + } + } + ] + } + ] + } +} diff --git a/tests/samples/azuro_condition_batch.json b/tests/samples/azuro_condition_batch.json new file mode 100644 index 0000000..b5d1a34 --- /dev/null +++ b/tests/samples/azuro_condition_batch.json @@ -0,0 +1,104 @@ +{ + "conditions": [ + { + "state": "Active", + "conditionId": "600610060000000000943019940000000000001406934671", + "sort": "1300", + "title": "Handicap 3-Way", + "category": "handicap_3_way", + "marketGroupKeys": [], + "outcomes": [ + { + "id": "600610060000000000943019940000000000001406934671_2", + "outcomeId": "3", + "odds": "6.83", + "point": "-1", + "sort": "0000004900", + "title": "Trefelin FC (-1)", + "state": "Active", + "hidden": false + }, + { + "id": "600610060000000000943019940000000000001406934671_0", + "outcomeId": "1", + "odds": "4.95", + "point": "+1", + "sort": "0010005100", + "title": "Draw (+1)", + "state": "Active", + "hidden": false + }, + { + "id": "600610060000000000943019940000000000001406934671_1", + "outcomeId": "2", + "odds": "1.32", + "point": "+1", + "sort": "0020005100", + "title": "Flint Town United (+1)", + "state": "Active", + "hidden": false + } + ] + }, + { + "state": "Active", + "conditionId": "600610060000000000943019940000000000001802657415", + "sort": "30300", + "title": "Total Corners Over/Under", + "category": "total", + "marketGroupKeys": [], + "outcomes": [ + { + "id": "600610060000000000943019940000000000001802657415_0", + "outcomeId": "1", + "odds": "2.04", + "point": "10.5", + "sort": "0000006050", + "title": "Over 10.5", + "state": "Active", + "hidden": false + }, + { + "id": "600610060000000000943019940000000000001802657415_1", + "outcomeId": "2", + "odds": "1.65", + "point": "10.5", + "sort": "0020006050", + "title": "Under 10.5", + "state": "Active", + "hidden": false + } + ] + }, + { + "state": "Resolved", + "conditionId": "600610060000000000028666020000000000001624778125", + "sort": "15000.00106", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "category": "total", + "marketGroupKeys": [], + "outcomes": [ + { + "id": "600610060000000000028666020000000000001624778125_1", + "outcomeId": "2", + "odds": "3.26", + "point": "11.5", + "sort": "0020006150", + "title": "Under 11.5", + "state": "Lost", + "hidden": true + }, + { + "id": "600610060000000000028666020000000000001624778125_0", + "outcomeId": "1", + "odds": "1.26", + "point": "11.5", + "sort": "0000006150", + "title": "Over 11.5", + "state": "Won", + "hidden": true + } + ] + } + ] +} diff --git a/tests/samples/azuro_conditions.json b/tests/samples/azuro_conditions.json new file mode 100644 index 0000000..a44da2f --- /dev/null +++ b/tests/samples/azuro_conditions.json @@ -0,0 +1,4609 @@ +{ + "conditions": [ + { + "id": "600610060000000000943019940000000000001802657415", + "conditionId": "600610060000000000943019940000000000001802657415", + "state": "Active", + "title": "Total Corners Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119974, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "30300", + "outcomes": [ + { + "title": "Over 10.5", + "outcomeId": "1", + "odds": "2.04", + "point": "10.5", + "sort": "0000006050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 10.5", + "outcomeId": "2", + "odds": "1.65", + "point": "10.5", + "sort": "0020006050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934638", + "conditionId": "600610060000000000943019940000000000001406934638", + "state": "Active", + "title": "Both Teams to Score", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 161990, + "marketVarietyId": null, + "category": "yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "350", + "outcomes": [ + { + "title": "Yes", + "outcomeId": "1", + "odds": "1.5", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "No", + "outcomeId": "2", + "odds": "2.34", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934689", + "conditionId": "600610060000000000943019940000000000001406934689", + "state": "Active", + "title": "Correct Score", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120981, + "marketVarietyId": null, + "category": "correct_score", + "marketGroupKeys": [], + "hidden": false, + "sort": "1150", + "outcomes": [ + { + "title": "5-3", + "outcomeId": "9", + "odds": "396", + "point": null, + "sort": "5300", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "4-2", + "outcomeId": "11", + "odds": "74.25", + "point": null, + "sort": "4200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-5", + "outcomeId": "20", + "odds": "89.1", + "point": null, + "sort": "5100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-3", + "outcomeId": "5", + "odds": "14.35", + "point": null, + "sort": "3100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "0-1", + "outcomeId": "1", + "odds": "9.9", + "point": null, + "sort": "1000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "4-3", + "outcomeId": "10", + "odds": "118.8", + "point": null, + "sort": "4300", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "4-1", + "outcomeId": "36", + "odds": "69.3", + "point": null, + "sort": "4100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "5-4", + "outcomeId": "37", + "odds": "792", + "point": null, + "sort": "5400", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "6-1", + "outcomeId": "16", + "odds": "990", + "point": null, + "sort": "6100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2-0", + "outcomeId": "17", + "odds": "19.8", + "point": null, + "sort": "2000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "6-0", + "outcomeId": "43", + "odds": "891", + "point": null, + "sort": "6000", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "6-2", + "outcomeId": "21", + "odds": "990", + "point": null, + "sort": "6200", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "4-4", + "outcomeId": "23", + "odds": "247.5", + "point": null, + "sort": "4", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2-1", + "outcomeId": "24", + "odds": "11.13", + "point": null, + "sort": "2100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-1", + "outcomeId": "25", + "odds": "7.12", + "point": null, + "sort": "1", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "0-4", + "outcomeId": "28", + "odds": "44.55", + "point": null, + "sort": "4000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "4-5", + "outcomeId": "34", + "odds": "594", + "point": null, + "sort": "5400", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "3-3", + "outcomeId": "38", + "odds": "39.6", + "point": null, + "sort": "3", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "0-3", + "outcomeId": "40", + "odds": "19.3", + "point": null, + "sort": "3000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-6", + "outcomeId": "27", + "odds": "297", + "point": null, + "sort": "6100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "3-2", + "outcomeId": "30", + "odds": "25.74", + "point": null, + "sort": "3200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "3-1", + "outcomeId": "41", + "odds": "24.75", + "point": null, + "sort": "3100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "0-5", + "outcomeId": "2", + "odds": "118.8", + "point": null, + "sort": "5000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "5-0", + "outcomeId": "3", + "odds": "445.5", + "point": null, + "sort": "5000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2-3", + "outcomeId": "4", + "odds": "19.8", + "point": null, + "sort": "3200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "5-2", + "outcomeId": "6", + "odds": "247.5", + "point": null, + "sort": "5200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "5-1", + "outcomeId": "7", + "odds": "247.5", + "point": null, + "sort": "5100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-0", + "outcomeId": "14", + "odds": "13.36", + "point": null, + "sort": "1000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "3-6", + "outcomeId": "18", + "odds": "792", + "point": null, + "sort": "6300", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-2", + "outcomeId": "19", + "odds": "8.41", + "point": null, + "sort": "2100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "0-0", + "outcomeId": "22", + "odds": "8.41", + "point": null, + "sort": "0", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-4", + "outcomeId": "26", + "odds": "31.68", + "point": null, + "sort": "4100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2-4", + "outcomeId": "29", + "odds": "44.55", + "point": null, + "sort": "4200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "3-0", + "outcomeId": "31", + "odds": "44.55", + "point": null, + "sort": "3000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2-6", + "outcomeId": "32", + "odds": "396", + "point": null, + "sort": "6200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "0-6", + "outcomeId": "35", + "odds": "297", + "point": null, + "sort": "6000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "3-4", + "outcomeId": "12", + "odds": "89.1", + "point": null, + "sort": "4300", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2-2", + "outcomeId": "13", + "odds": "11.63", + "point": null, + "sort": "2", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "3-5", + "outcomeId": "15", + "odds": "247.5", + "point": null, + "sort": "5300", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "0-2", + "outcomeId": "8", + "odds": "11.63", + "point": null, + "sort": "2000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "1-7", + "outcomeId": "42", + "odds": "990", + "point": null, + "sort": "7100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2-5", + "outcomeId": "33", + "odds": "118.8", + "point": null, + "sort": "5200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "4-0", + "outcomeId": "39", + "odds": "118.8", + "point": null, + "sort": "4000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934651", + "conditionId": "600610060000000000943019940000000000001406934651", + "state": "Active", + "title": "Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120945, + "marketVarietyId": 1961991338, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1500", + "outcomes": [ + { + "title": "Over 0.5", + "outcomeId": "1", + "odds": "1.29", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 0.5", + "outcomeId": "2", + "odds": "3.26", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934671", + "conditionId": "600610060000000000943019940000000000001406934671", + "state": "Active", + "title": "Handicap 3-Way", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121003, + "marketVarietyId": null, + "category": "handicap_3_way", + "marketGroupKeys": [], + "hidden": false, + "sort": "1300", + "outcomes": [ + { + "title": "Trefelin FC (-1)", + "outcomeId": "3", + "odds": "6.83", + "point": "-1", + "sort": "0000004900", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw (+1)", + "outcomeId": "1", + "odds": "4.95", + "point": "+1", + "sort": "0010005100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United (+1)", + "outcomeId": "2", + "odds": "1.32", + "point": "+1", + "sort": "0020005100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934744", + "conditionId": "600610060000000000943019940000000000001406934744", + "state": "Active", + "title": "2nd Half - Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120999, + "marketVarietyId": 608169280, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "5500.002", + "outcomes": [ + { + "title": "Over 1.5", + "outcomeId": "1", + "odds": "1.76", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 1.5", + "outcomeId": "2", + "odds": "1.91", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934772", + "conditionId": "600610060000000000943019940000000000001406934772", + "state": "Active", + "title": "2nd Half - Double Chance", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120314, + "marketVarietyId": 1917349475, + "category": "double_chance", + "marketGroupKeys": [], + "hidden": false, + "sort": "5400.002", + "outcomes": [ + { + "title": "Trefelin FC/Flint Town United", + "outcomeId": "3", + "odds": "1.32", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw", + "outcomeId": "1", + "odds": "1.45", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United", + "outcomeId": "2", + "odds": "1.22", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934823", + "conditionId": "600610060000000000943019940000000000001406934823", + "state": "Active", + "title": "Both Teams to Score and Total Goals Over/Under 2.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121037, + "marketVarietyId": 1530910139, + "category": "total_and_yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "850.2", + "outcomes": [ + { + "title": "Under & No (2.5)", + "outcomeId": "1", + "odds": "2.97", + "point": "2.5", + "sort": "0020015250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over & Yes (2.5)", + "outcomeId": "2", + "odds": "1.83", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under & Yes (2.5)", + "outcomeId": "3", + "odds": "7.12", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over & No (2.5)", + "outcomeId": "4", + "odds": "9.15", + "point": "2.5", + "sort": "0000015250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934730", + "conditionId": "600610060000000000943019940000000000001406934730", + "state": "Active", + "title": "1st Half - Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121021, + "marketVarietyId": 1989073333, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3350.001", + "outcomes": [ + { + "title": "Under 0.5", + "outcomeId": "2", + "odds": "1.63", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 0.5", + "outcomeId": "1", + "odds": "2.07", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934758", + "conditionId": "600610060000000000943019940000000000001406934758", + "state": "Active", + "title": "1st Half - Draw No Bet", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 12855, + "marketVarietyId": 1390076498, + "category": "winner", + "marketGroupKeys": [], + "hidden": false, + "sort": "3250.001", + "outcomes": [ + { + "title": "Flint Town United", + "outcomeId": "1", + "odds": "1.55", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC", + "outcomeId": "2", + "odds": "2.22", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934849", + "conditionId": "600610060000000000943019940000000000001406934849", + "state": "Active", + "title": "Asian Handicap (0-0)", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 161924, + "marketVarietyId": 11921200, + "category": "handicap", + "marketGroupKeys": [], + "hidden": false, + "sort": "1245", + "outcomes": [ + { + "title": "Trefelin FC (+0.5)", + "outcomeId": "1", + "odds": "1.76", + "point": "+0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United (-0.5)", + "outcomeId": "2", + "odds": "1.96", + "point": "-0.5", + "sort": "0020004950", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934659", + "conditionId": "600610060000000000943019940000000000001406934659", + "state": "Active", + "title": "Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120980, + "marketVarietyId": 1974431181, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1550", + "outcomes": [ + { + "title": "Under 0.5", + "outcomeId": "2", + "odds": "4.6", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 0.5", + "outcomeId": "1", + "odds": "1.15", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934817", + "conditionId": "600610060000000000943019940000000000001406934817", + "state": "Active", + "title": "Double Chance and Total Goals Over/Under 4.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 12890, + "marketVarietyId": 1395317203, + "category": "participant_and_total", + "marketGroupKeys": [], + "hidden": false, + "sort": "950.4", + "outcomes": [ + { + "title": "Trefelin FC/Draw & Over (4.5)", + "outcomeId": "3", + "odds": "9.15", + "point": "4.5", + "sort": "0000005450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & Under (4.5)", + "outcomeId": "5", + "odds": "1.6", + "point": "4.5", + "sort": "0020025450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Under (4.5)", + "outcomeId": "6", + "odds": "1.63", + "point": "4.5", + "sort": "0020015450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & Over (4.5)", + "outcomeId": "1", + "odds": "5.94", + "point": "4.5", + "sort": "0000025450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw & Under (4.5)", + "outcomeId": "4", + "odds": "2.02", + "point": "4.5", + "sort": "0020005450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Over (4.5)", + "outcomeId": "2", + "odds": "4.7", + "point": "4.5", + "sort": "0000015450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934667", + "conditionId": "600610060000000000943019940000000000001406934667", + "state": "Active", + "title": "Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120980, + "marketVarietyId": 1974431181, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1550", + "outcomes": [ + { + "title": "Over 4.5", + "outcomeId": "2", + "odds": "10.89", + "point": "4.5", + "sort": "0000005450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 4.5", + "outcomeId": "1", + "odds": "1", + "point": "4.5", + "sort": "0020005450", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934760", + "conditionId": "600610060000000000943019940000000000001406934760", + "state": "Active", + "title": "Goal Scored in Both Halves", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121016, + "marketVarietyId": null, + "category": "yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "8000", + "outcomes": [ + { + "title": "Yes", + "outcomeId": "1", + "odds": "1.55", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "No", + "outcomeId": "2", + "odds": "2.22", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934779", + "conditionId": "600610060000000000943019940000000000001406934779", + "state": "Active", + "title": "2nd Half - Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121021, + "marketVarietyId": 1989073334, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "6200.002", + "outcomes": [ + { + "title": "Over 2.5", + "outcomeId": "1", + "odds": "9.15", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 2.5", + "outcomeId": "2", + "odds": "1.01", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934831", + "conditionId": "600610060000000000943019940000000000001406934831", + "state": "Active", + "title": "Match Result and Both Teams to Score", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120541, + "marketVarietyId": null, + "category": "participant_and_yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "775", + "outcomes": [ + { + "title": "Draw & No", + "outcomeId": "1", + "odds": "8.41", + "point": null, + "sort": "0010010000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC & Yes", + "outcomeId": "2", + "odds": "5.14", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC & No", + "outcomeId": "3", + "odds": "6.83", + "point": null, + "sort": "0000010000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw & Yes", + "outcomeId": "4", + "odds": "4.15", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United & No", + "outcomeId": "5", + "odds": "4", + "point": null, + "sort": "0020010000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United & Yes", + "outcomeId": "6", + "odds": "3.36", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934853", + "conditionId": "600610060000000000943019940000000000001406934853", + "state": "Active", + "title": "1st Half - Asian Handicap (0-0)", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 161925, + "marketVarietyId": 74492184, + "category": "handicap", + "marketGroupKeys": [], + "hidden": false, + "sort": "3225.001", + "outcomes": [ + { + "title": "Flint Town United (0.0)", + "outcomeId": "1", + "odds": "1.58", + "point": "0.0", + "sort": "0020005000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC (0.0)", + "outcomeId": "2", + "odds": "2.24", + "point": "0.0", + "sort": "0000005000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934629", + "conditionId": "600610060000000000943019940000000000001406934629", + "state": "Active", + "title": "Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120989, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "200", + "outcomes": [ + { + "title": "Over 4.5", + "outcomeId": "1", + "odds": "3.96", + "point": "4.5", + "sort": "0000005450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 4.5", + "outcomeId": "2", + "odds": "1.18", + "point": "4.5", + "sort": "0020005450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934653", + "conditionId": "600610060000000000943019940000000000001406934653", + "state": "Active", + "title": "Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120945, + "marketVarietyId": 1961991338, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1500", + "outcomes": [ + { + "title": "Under 1.5", + "outcomeId": "2", + "odds": "1.53", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 1.5", + "outcomeId": "1", + "odds": "2.34", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934680", + "conditionId": "600610060000000000943019940000000000001406934680", + "state": "Active", + "title": "Half Time / Full Time", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120906, + "marketVarietyId": null, + "category": "participant_slash_participant", + "marketGroupKeys": [], + "hidden": false, + "sort": "1100", + "outcomes": [ + { + "title": "Draw/Flint Town United", + "outcomeId": "4", + "odds": "5.04", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United/Trefelin FC", + "outcomeId": "5", + "odds": "29.7", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United/Draw", + "outcomeId": "6", + "odds": "12.87", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United", + "outcomeId": "9", + "odds": "20.79", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Trefelin FC", + "outcomeId": "7", + "odds": "5.34", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw", + "outcomeId": "2", + "odds": "12.87", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United/Flint Town United", + "outcomeId": "1", + "odds": "3.16", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Trefelin FC", + "outcomeId": "3", + "odds": "7.32", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Draw", + "outcomeId": "8", + "odds": "5.64", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934734", + "conditionId": "600610060000000000943019940000000000001406934734", + "state": "Active", + "title": "1st Half - Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121021, + "marketVarietyId": 1989073333, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3350.001", + "outcomes": [ + { + "title": "Over 2.5", + "outcomeId": "1", + "odds": "10.89", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 2.5", + "outcomeId": "2", + "odds": "1", + "point": "2.5", + "sort": "0020005250", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934625", + "conditionId": "600610060000000000943019940000000000001406934625", + "state": "Active", + "title": "Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120989, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "200", + "outcomes": [ + { + "title": "Under 2.5", + "outcomeId": "1", + "odds": "2.17", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 2.5", + "outcomeId": "2", + "odds": "1.58", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934633", + "conditionId": "600610060000000000943019940000000000001406934633", + "state": "Active", + "title": "Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120989, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "200", + "outcomes": [ + { + "title": "Under 6.5", + "outcomeId": "1", + "odds": "1", + "point": "6.5", + "sort": "0020005650", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 6.5", + "outcomeId": "2", + "odds": "9.4", + "point": "6.5", + "sort": "0000005650", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934787", + "conditionId": "600610060000000000943019940000000000001406934787", + "state": "Active", + "title": "Match Result and Total Goals Over/Under 2.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119933, + "marketVarietyId": 445565695, + "category": "participant_and_total", + "marketGroupKeys": [], + "hidden": false, + "sort": "600.2", + "outcomes": [ + { + "title": "Draw & Over (2.5)", + "outcomeId": "1", + "odds": "8.91", + "point": "2.5", + "sort": "0000015250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC & Over (2.5)", + "outcomeId": "6", + "odds": "4.6", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United & Over (2.5)", + "outcomeId": "3", + "odds": "2.79", + "point": "2.5", + "sort": "0000025250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw & Under (2.5)", + "outcomeId": "4", + "odds": "5.04", + "point": "2.5", + "sort": "0020015250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC & Under (2.5)", + "outcomeId": "5", + "odds": "8.16", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United & Under (2.5)", + "outcomeId": "2", + "odds": "5.54", + "point": "2.5", + "sort": "0020025250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934643", + "conditionId": "600610060000000000943019940000000000001406934643", + "state": "Active", + "title": "1st Half - Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120999, + "marketVarietyId": 608169279, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3000.001", + "outcomes": [ + { + "title": "Over 0.5", + "outcomeId": "1", + "odds": "1.3", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 0.5", + "outcomeId": "2", + "odds": "3.06", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934746", + "conditionId": "600610060000000000943019940000000000001406934746", + "state": "Active", + "title": "2nd Half - Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120999, + "marketVarietyId": 608169280, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "5500.002", + "outcomes": [ + { + "title": "Over 2.5", + "outcomeId": "1", + "odds": "3.16", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 2.5", + "outcomeId": "2", + "odds": "1.28", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001802657509", + "conditionId": "600610060000000000943019940000000000001802657509", + "state": "Active", + "title": "1st Half - Total Corners 3-Way", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 156004, + "marketVarietyId": 1289714279, + "category": "total_3_way", + "marketGroupKeys": [], + "hidden": false, + "sort": "40000.00108", + "outcomes": [ + { + "title": "Over 5", + "outcomeId": "1", + "odds": "2.27", + "point": "5", + "sort": "0000005500", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Exactly 5", + "outcomeId": "2", + "odds": "3.96", + "point": "5", + "sort": "0010005500", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 5", + "outcomeId": "3", + "odds": "1.96", + "point": "5", + "sort": "0020005500", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934753", + "conditionId": "600610060000000000943019940000000000001406934753", + "state": "Active", + "title": "1st Half - Handicap 3-Way", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121055, + "marketVarietyId": 1127696873, + "category": "handicap_3_way", + "marketGroupKeys": [], + "hidden": false, + "sort": "3340.001", + "outcomes": [ + { + "title": "Trefelin FC (+1)", + "outcomeId": "1", + "odds": "1.45", + "point": "+1", + "sort": "0000005100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United (-1)", + "outcomeId": "2", + "odds": "7.52", + "point": "-1", + "sort": "0020004900", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw (-1)", + "outcomeId": "3", + "odds": "3.51", + "point": "-1", + "sort": "0010004900", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934623", + "conditionId": "600610060000000000943019940000000000001406934623", + "state": "Active", + "title": "Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120989, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "200", + "outcomes": [ + { + "title": "Over 1.5", + "outcomeId": "1", + "odds": "1.18", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 1.5", + "outcomeId": "2", + "odds": "4.05", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001802657413", + "conditionId": "600610060000000000943019940000000000001802657413", + "state": "Active", + "title": "Total Corners Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119974, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "30300", + "outcomes": [ + { + "title": "Under 9.5", + "outcomeId": "1", + "odds": "2.09", + "point": "9.5", + "sort": "0020005950", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 9.5", + "outcomeId": "2", + "odds": "1.63", + "point": "9.5", + "sort": "0000005950", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934750", + "conditionId": "600610060000000000943019940000000000001406934750", + "state": "Active", + "title": "1st Half - Double Chance", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120314, + "marketVarietyId": 1917349474, + "category": "double_chance", + "marketGroupKeys": [], + "hidden": false, + "sort": "2950.001", + "outcomes": [ + { + "title": "Draw/Flint Town United", + "outcomeId": "2", + "odds": "1.17", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United", + "outcomeId": "1", + "odds": "1.47", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw", + "outcomeId": "3", + "odds": "1.37", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934635", + "conditionId": "600610060000000000943019940000000000001406934635", + "state": "Active", + "title": "Double Chance", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120897, + "marketVarietyId": null, + "category": "double_chance", + "marketGroupKeys": [], + "hidden": false, + "sort": "450", + "outcomes": [ + { + "title": "Trefelin FC/Flint Town United", + "outcomeId": "1", + "odds": "1.25", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw", + "outcomeId": "2", + "odds": "1.76", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United", + "outcomeId": "3", + "odds": "1.32", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934665", + "conditionId": "600610060000000000943019940000000000001406934665", + "state": "Active", + "title": "Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120980, + "marketVarietyId": 1974431181, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1550", + "outcomes": [ + { + "title": "Over 3.5", + "outcomeId": "2", + "odds": "6.63", + "point": "3.5", + "sort": "0000005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 3.5", + "outcomeId": "1", + "odds": "1.07", + "point": "3.5", + "sort": "0020005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934742", + "conditionId": "600610060000000000943019940000000000001406934742", + "state": "Active", + "title": "2nd Half - Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120999, + "marketVarietyId": 608169280, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "5500.002", + "outcomes": [ + { + "title": "Under 0.5", + "outcomeId": "1", + "odds": "4.35", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 0.5", + "outcomeId": "2", + "odds": "1.15", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934621", + "conditionId": "600610060000000000943019940000000000001406934621", + "state": "Active", + "title": "Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120989, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "200", + "outcomes": [ + { + "title": "Over 0.5", + "outcomeId": "1", + "odds": "1.02", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 0.5", + "outcomeId": "2", + "odds": "8.41", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934640", + "conditionId": "600610060000000000943019940000000000001406934640", + "state": "Active", + "title": "1st Half - Result", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120895, + "marketVarietyId": 710315620, + "category": "result", + "marketGroupKeys": [], + "hidden": false, + "sort": "1050.001", + "outcomes": [ + { + "title": "Trefelin FC", + "outcomeId": "3", + "odds": "3.61", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw", + "outcomeId": "1", + "odds": "2.22", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United", + "outcomeId": "2", + "odds": "2.54", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934765", + "conditionId": "600610060000000000943019940000000000001406934765", + "state": "Active", + "title": "Trefelin FC to Win Both Halves", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121013, + "marketVarietyId": 51679763, + "category": "yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "8800", + "outcomes": [ + { + "title": "Yes", + "outcomeId": "1", + "odds": "7.02", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "No", + "outcomeId": "2", + "odds": "1.02", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934674", + "conditionId": "600610060000000000943019940000000000001406934674", + "state": "Active", + "title": "Handicap 3-Way", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121003, + "marketVarietyId": null, + "category": "handicap_3_way", + "marketGroupKeys": [], + "hidden": false, + "sort": "1300", + "outcomes": [ + { + "title": "Draw (-1)", + "outcomeId": "1", + "odds": "3.91", + "point": "-1", + "sort": "0010004900", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC (+1)", + "outcomeId": "2", + "odds": "1.76", + "point": "+1", + "sort": "0000005100", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United (-1)", + "outcomeId": "3", + "odds": "3.61", + "point": "-1", + "sort": "0020004900", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934851", + "conditionId": "600610060000000000943019940000000000001406934851", + "state": "Stopped", + "title": "1st Half - Asian Handicap (0-0)", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 161925, + "marketVarietyId": 74492184, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "3225.001", + "outcomes": [ + { + "title": "Flint Town United (+0.5)", + "outcomeId": "1", + "odds": "1.28", + "point": "+0.5", + "sort": "0020005050", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC (-0.5)", + "outcomeId": "2", + "odds": "3.31", + "point": "-0.5", + "sort": "0000004950", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934661", + "conditionId": "600610060000000000943019940000000000001406934661", + "state": "Active", + "title": "Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120980, + "marketVarietyId": 1974431181, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1550", + "outcomes": [ + { + "title": "Under 1.5", + "outcomeId": "1", + "odds": "1.93", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 1.5", + "outcomeId": "2", + "odds": "1.78", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934738", + "conditionId": "600610060000000000943019940000000000001406934738", + "state": "Active", + "title": "1st Half - Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121020, + "marketVarietyId": 1385048898, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3400.001", + "outcomes": [ + { + "title": "Over 1.5", + "outcomeId": "2", + "odds": "4.4", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 1.5", + "outcomeId": "1", + "odds": "1.15", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934762", + "conditionId": "600610060000000000943019940000000000001406934762", + "state": "Active", + "title": "Half With Most Goals", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121011, + "marketVarietyId": null, + "category": "result", + "marketGroupKeys": [], + "hidden": false, + "sort": "9100", + "outcomes": [ + { + "title": "1st Half", + "outcomeId": "2", + "odds": "3.06", + "point": null, + "sort": "0", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "2nd Half", + "outcomeId": "1", + "odds": "1.88", + "point": null, + "sort": "0", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Neither", + "outcomeId": "3", + "odds": "3.51", + "point": null, + "sort": "0", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934785", + "conditionId": "600610060000000000943019940000000000001406934785", + "state": "Active", + "title": "2nd Half - Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121020, + "marketVarietyId": 1385048899, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "6300.002", + "outcomes": [ + { + "title": "Under 2.5", + "outcomeId": "1", + "odds": "1.05", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 2.5", + "outcomeId": "2", + "odds": "7.02", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001855472620", + "conditionId": "600610060000000000943019940000000000001855472620", + "state": "Stopped", + "title": "Asian Handicap (0-0)", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 161924, + "marketVarietyId": 11921200, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "1245", + "outcomes": [ + { + "title": "Trefelin FC (+1.0)", + "outcomeId": "1", + "odds": "1.4", + "point": "+1.0", + "sort": "0000005100", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Flint Town United (-1.0)", + "outcomeId": "2", + "odds": "2.72", + "point": "-1.0", + "sort": "0020004900", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934647", + "conditionId": "600610060000000000943019940000000000001406934647", + "state": "Active", + "title": "1st Half - Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120999, + "marketVarietyId": 608169279, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3000.001", + "outcomes": [ + { + "title": "Over 2.5", + "outcomeId": "2", + "odds": "4.85", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 2.5", + "outcomeId": "1", + "odds": "1.12", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934618", + "conditionId": "600610060000000000943019940000000000001406934618", + "state": "Active", + "title": "Match Result", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120890, + "marketVarietyId": null, + "category": "result", + "marketGroupKeys": [], + "hidden": false, + "sort": "50", + "outcomes": [ + { + "title": "Trefelin FC", + "outcomeId": "1", + "odds": "3.16", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United", + "outcomeId": "2", + "odds": "1.98", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw", + "outcomeId": "3", + "odds": "3.46", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934732", + "conditionId": "600610060000000000943019940000000000001406934732", + "state": "Active", + "title": "1st Half - Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121021, + "marketVarietyId": 1989073333, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3350.001", + "outcomes": [ + { + "title": "Under 1.5", + "outcomeId": "2", + "odds": "1.08", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 1.5", + "outcomeId": "1", + "odds": "5.84", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934740", + "conditionId": "600610060000000000943019940000000000001406934740", + "state": "Active", + "title": "1st Half - Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121020, + "marketVarietyId": 1385048898, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3400.001", + "outcomes": [ + { + "title": "Under 2.5", + "outcomeId": "1", + "odds": "1", + "point": "2.5", + "sort": "0020005250", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 2.5", + "outcomeId": "2", + "odds": "9.4", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934781", + "conditionId": "600610060000000000943019940000000000001406934781", + "state": "Active", + "title": "2nd Half - Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121020, + "marketVarietyId": 1385048899, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "6300.002", + "outcomes": [ + { + "title": "Under 0.5", + "outcomeId": "2", + "odds": "2.39", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 0.5", + "outcomeId": "1", + "odds": "1.48", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934805", + "conditionId": "600610060000000000943019940000000000001406934805", + "state": "Active", + "title": "Double Chance and Total Goals Over/Under 2.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 12890, + "marketVarietyId": 1395315281, + "category": "participant_and_total", + "marketGroupKeys": [], + "hidden": false, + "sort": "950.2", + "outcomes": [ + { + "title": "Trefelin FC/Draw & Over (2.5)", + "outcomeId": "1", + "odds": "3.21", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & Over (2.5)", + "outcomeId": "3", + "odds": "2.24", + "point": "2.5", + "sort": "0000025250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Under (2.5)", + "outcomeId": "4", + "odds": "3.56", + "point": "2.5", + "sort": "0020015250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw & Under (2.5)", + "outcomeId": "5", + "odds": "3.36", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & Under (2.5)", + "outcomeId": "6", + "odds": "2.84", + "point": "2.5", + "sort": "0020025250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Over (2.5)", + "outcomeId": "2", + "odds": "1.85", + "point": "2.5", + "sort": "0000015250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934631", + "conditionId": "600610060000000000943019940000000000001406934631", + "state": "Active", + "title": "Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120989, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "200", + "outcomes": [ + { + "title": "Over 5.5", + "outcomeId": "1", + "odds": "6.53", + "point": "5.5", + "sort": "0000005550", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 5.5", + "outcomeId": "2", + "odds": "1.06", + "point": "5.5", + "sort": "0020005550", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934669", + "conditionId": "600610060000000000943019940000000000001406934669", + "state": "Active", + "title": "Draw No Bet", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120522, + "marketVarietyId": null, + "category": "winner", + "marketGroupKeys": [], + "hidden": false, + "sort": "500", + "outcomes": [ + { + "title": "Flint Town United", + "outcomeId": "1", + "odds": "1.5", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC", + "outcomeId": "2", + "odds": "2.34", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934793", + "conditionId": "600610060000000000943019940000000000001406934793", + "state": "Active", + "title": "Match Result and Total Goals Over/Under 3.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119933, + "marketVarietyId": 445566656, + "category": "participant_and_total", + "marketGroupKeys": [], + "hidden": false, + "sort": "600.3", + "outcomes": [ + { + "title": "Draw & Over (3.5)", + "outcomeId": "1", + "odds": "8.91", + "point": "3.5", + "sort": "0000015350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC & Over (3.5)", + "outcomeId": "3", + "odds": "8.16", + "point": "3.5", + "sort": "0000005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United & Under (3.5)", + "outcomeId": "4", + "odds": "3.06", + "point": "3.5", + "sort": "0020025350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw & Under (3.5)", + "outcomeId": "5", + "odds": "5.04", + "point": "3.5", + "sort": "0020015350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United & Over (3.5)", + "outcomeId": "6", + "odds": "4.6", + "point": "3.5", + "sort": "0000025350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC & Under (3.5)", + "outcomeId": "2", + "odds": "4.6", + "point": "3.5", + "sort": "0020005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934843", + "conditionId": "600610060000000000943019940000000000001406934843", + "state": "Active", + "title": "Trefelin FC to Score in Both Halves", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121033, + "marketVarietyId": 1612357397, + "category": "yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "8200", + "outcomes": [ + { + "title": "Yes", + "outcomeId": "1", + "odds": "3.51", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "No", + "outcomeId": "2", + "odds": "1.23", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934845", + "conditionId": "600610060000000000943019940000000000001406934845", + "state": "Active", + "title": "Flint Town United to Score in Both Halves", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121009, + "marketVarietyId": 1936661172, + "category": "yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "8300", + "outcomes": [ + { + "title": "Yes", + "outcomeId": "1", + "odds": "2.62", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "No", + "outcomeId": "2", + "odds": "1.4", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934657", + "conditionId": "600610060000000000943019940000000000001406934657", + "state": "Active", + "title": "Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120945, + "marketVarietyId": 1961991338, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1500", + "outcomes": [ + { + "title": "Under 3.5", + "outcomeId": "1", + "odds": "1.01", + "point": "3.5", + "sort": "0020005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 3.5", + "outcomeId": "2", + "odds": "9.65", + "point": "3.5", + "sort": "0000005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934769", + "conditionId": "600610060000000000943019940000000000001406934769", + "state": "Active", + "title": "2nd Half - Result", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120895, + "marketVarietyId": 710315621, + "category": "result", + "marketGroupKeys": [], + "hidden": false, + "sort": "5300.002", + "outcomes": [ + { + "title": "Trefelin FC", + "outcomeId": "3", + "odds": "3.31", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United", + "outcomeId": "2", + "odds": "2.27", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw", + "outcomeId": "1", + "odds": "2.69", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934627", + "conditionId": "600610060000000000943019940000000000001406934627", + "state": "Active", + "title": "Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120989, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "200", + "outcomes": [ + { + "title": "Over 3.5", + "outcomeId": "2", + "odds": "2.39", + "point": "3.5", + "sort": "0000005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 3.5", + "outcomeId": "1", + "odds": "1.48", + "point": "3.5", + "sort": "0020005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934645", + "conditionId": "600610060000000000943019940000000000001406934645", + "state": "Active", + "title": "1st Half - Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120999, + "marketVarietyId": 608169279, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3000.001", + "outcomes": [ + { + "title": "Over 1.5", + "outcomeId": "1", + "odds": "2.34", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 1.5", + "outcomeId": "2", + "odds": "1.5", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001802657426", + "conditionId": "600610060000000000943019940000000000001802657426", + "state": "Active", + "title": "1st Half - Total Corners Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121071, + "marketVarietyId": 1279558895, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "40000.00103", + "outcomes": [ + { + "title": "Over 4.5", + "outcomeId": "1", + "odds": "1.73", + "point": "4.5", + "sort": "0000005450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 4.5", + "outcomeId": "2", + "odds": "1.96", + "point": "4.5", + "sort": "0020005450", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934677", + "conditionId": "600610060000000000943019940000000000001406934677", + "state": "Active", + "title": "Handicap 3-Way", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121003, + "marketVarietyId": null, + "category": "handicap_3_way", + "marketGroupKeys": [], + "hidden": false, + "sort": "1300", + "outcomes": [ + { + "title": "Draw (-2)", + "outcomeId": "2", + "odds": "5.64", + "point": "-2", + "sort": "0010004800", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC (+2)", + "outcomeId": "3", + "odds": "1.26", + "point": "+2", + "sort": "0000005200", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United (-2)", + "outcomeId": "1", + "odds": "7.42", + "point": "-2", + "sort": "0020004800", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934736", + "conditionId": "600610060000000000943019940000000000001406934736", + "state": "Active", + "title": "1st Half - Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121020, + "marketVarietyId": 1385048898, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3400.001", + "outcomes": [ + { + "title": "Under 0.5", + "outcomeId": "1", + "odds": "1.93", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 0.5", + "outcomeId": "2", + "odds": "1.73", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934756", + "conditionId": "600610060000000000943019940000000000001406934756", + "state": "Active", + "title": "1st Half - Both Teams to Score", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120899, + "marketVarietyId": 1016993504, + "category": "yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "3020.001", + "outcomes": [ + { + "title": "Yes", + "outcomeId": "2", + "odds": "3.28", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "No", + "outcomeId": "1", + "odds": "1.19", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934767", + "conditionId": "600610060000000000943019940000000000001406934767", + "state": "Active", + "title": "Flint Town United to Win Both Halves", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121048, + "marketVarietyId": 2102459759, + "category": "yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "8900", + "outcomes": [ + { + "title": "No", + "outcomeId": "1", + "odds": "1.1", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Yes", + "outcomeId": "2", + "odds": "4.75", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934775", + "conditionId": "600610060000000000943019940000000000001406934775", + "state": "Active", + "title": "2nd Half - Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121021, + "marketVarietyId": 1989073334, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "6200.002", + "outcomes": [ + { + "title": "Over 0.5", + "outcomeId": "2", + "odds": "1.73", + "point": "0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 0.5", + "outcomeId": "1", + "odds": "1.96", + "point": "0.5", + "sort": "0020005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934783", + "conditionId": "600610060000000000943019940000000000001406934783", + "state": "Active", + "title": "2nd Half - Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121020, + "marketVarietyId": 1385048899, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "6300.002", + "outcomes": [ + { + "title": "Over 1.5", + "outcomeId": "1", + "odds": "3.11", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 1.5", + "outcomeId": "2", + "odds": "1.29", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934811", + "conditionId": "600610060000000000943019940000000000001406934811", + "state": "Active", + "title": "Double Chance and Total Goals Over/Under 3.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 12890, + "marketVarietyId": 1395316242, + "category": "participant_and_total", + "marketGroupKeys": [], + "hidden": false, + "sort": "950.3", + "outcomes": [ + { + "title": "Trefelin FC/Draw & Under (3.5)", + "outcomeId": "1", + "odds": "2.59", + "point": "3.5", + "sort": "0020005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & Over (3.5)", + "outcomeId": "4", + "odds": "3.21", + "point": "3.5", + "sort": "0000025350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Over (3.5)", + "outcomeId": "5", + "odds": "3.16", + "point": "3.5", + "sort": "0000015350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Under (3.5)", + "outcomeId": "6", + "odds": "1.98", + "point": "3.5", + "sort": "0020015350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & Under (3.5)", + "outcomeId": "2", + "odds": "2.04", + "point": "3.5", + "sort": "0020025350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw & Over (3.5)", + "outcomeId": "3", + "odds": "4.6", + "point": "3.5", + "sort": "0000005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934827", + "conditionId": "600610060000000000943019940000000000001406934827", + "state": "Active", + "title": "Both Teams to Score and Total Goals Over/Under 3.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121037, + "marketVarietyId": 1530911100, + "category": "total_and_yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "850.3", + "outcomes": [ + { + "title": "Under & No (3.5)", + "outcomeId": "3", + "odds": "2.54", + "point": "3.5", + "sort": "0020015350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over & No (3.5)", + "outcomeId": "2", + "odds": "22.77", + "point": "3.5", + "sort": "0000015350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over & Yes (3.5)", + "outcomeId": "1", + "odds": "2.62", + "point": "3.5", + "sort": "0000005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under & Yes (3.5)", + "outcomeId": "4", + "odds": "3.06", + "point": "3.5", + "sort": "0020005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934837", + "conditionId": "600610060000000000943019940000000000001406934837", + "state": "Active", + "title": "Double Chance and Both Teams to Score", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120537, + "marketVarietyId": null, + "category": "participant_and_yes_no", + "marketGroupKeys": [], + "hidden": false, + "sort": "1000", + "outcomes": [ + { + "title": "Draw/Flint Town United & Yes", + "outcomeId": "1", + "odds": "2.02", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw & Yes", + "outcomeId": "6", + "odds": "2.49", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Yes", + "outcomeId": "2", + "odds": "2.19", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw & No", + "outcomeId": "3", + "odds": "5.14", + "point": null, + "sort": "0000010000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & No", + "outcomeId": "4", + "odds": "3.41", + "point": null, + "sort": "0020010000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & No", + "outcomeId": "5", + "odds": "2.74", + "point": null, + "sort": "0010010000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934855", + "conditionId": "600610060000000000943019940000000000001406934855", + "state": "Active", + "title": "1st Half - Asian Handicap (0-0)", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 161925, + "marketVarietyId": 74492184, + "category": "handicap", + "marketGroupKeys": [], + "hidden": false, + "sort": "3225.001", + "outcomes": [ + { + "title": "Trefelin FC (+0.5)", + "outcomeId": "1", + "odds": "1.45", + "point": "+0.5", + "sort": "0000005050", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United (-0.5)", + "outcomeId": "2", + "odds": "2.52", + "point": "-0.5", + "sort": "0020004950", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934649", + "conditionId": "600610060000000000943019940000000000001406934649", + "state": "Active", + "title": "1st Half - Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120999, + "marketVarietyId": 608169279, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "3000.001", + "outcomes": [ + { + "title": "Under 3.5", + "outcomeId": "1", + "odds": "1.01", + "point": "3.5", + "sort": "0020005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 3.5", + "outcomeId": "2", + "odds": "8.91", + "point": "3.5", + "sort": "0000005350", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934663", + "conditionId": "600610060000000000943019940000000000001406934663", + "state": "Active", + "title": "Flint Town United Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120980, + "marketVarietyId": 1974431181, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1550", + "outcomes": [ + { + "title": "Under 2.5", + "outcomeId": "1", + "odds": "1.27", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 2.5", + "outcomeId": "2", + "odds": "3.36", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000002005884112", + "conditionId": "600610060000000000943019940000000000002005884112", + "state": "Stopped", + "title": "Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120945, + "marketVarietyId": 1961991338, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "1500", + "outcomes": [ + { + "title": "Under 4.5", + "outcomeId": "1", + "odds": "1", + "point": "4.5", + "sort": "0020005450", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 4.5", + "outcomeId": "2", + "odds": "12.37", + "point": "4.5", + "sort": "0000005450", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001802657417", + "conditionId": "600610060000000000943019940000000000001802657417", + "state": "Active", + "title": "Total Corners Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119974, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "30300", + "outcomes": [ + { + "title": "Over 11.5", + "outcomeId": "1", + "odds": "2.69", + "point": "11.5", + "sort": "0000006150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 11.5", + "outcomeId": "2", + "odds": "1.38", + "point": "11.5", + "sort": "0020006150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934655", + "conditionId": "600610060000000000943019940000000000001406934655", + "state": "Active", + "title": "Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120945, + "marketVarietyId": 1961991338, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "1500", + "outcomes": [ + { + "title": "Under 2.5", + "outcomeId": "1", + "odds": "1.12", + "point": "2.5", + "sort": "0020005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Over 2.5", + "outcomeId": "2", + "odds": "5.04", + "point": "2.5", + "sort": "0000005250", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934748", + "conditionId": "600610060000000000943019940000000000001406934748", + "state": "Active", + "title": "Total Goals Odd/Even", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120892, + "marketVarietyId": null, + "category": "odd_even", + "marketGroupKeys": [], + "hidden": false, + "sort": "1450", + "outcomes": [ + { + "title": "Even", + "outcomeId": "1", + "odds": "1.81", + "point": null, + "sort": "0010000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Odd", + "outcomeId": "2", + "odds": "1.86", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934777", + "conditionId": "600610060000000000943019940000000000001406934777", + "state": "Active", + "title": "2nd Half - Trefelin FC Total Goals Over/Under", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 121021, + "marketVarietyId": 1989073334, + "category": "total", + "marketGroupKeys": [], + "hidden": false, + "sort": "6200.002", + "outcomes": [ + { + "title": "Over 1.5", + "outcomeId": "1", + "odds": "4.25", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Under 1.5", + "outcomeId": "2", + "odds": "1.16", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934799", + "conditionId": "600610060000000000943019940000000000001406934799", + "state": "Active", + "title": "Double Chance and Total Goals Over/Under 1.5", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 12890, + "marketVarietyId": 1395314320, + "category": "participant_and_total", + "marketGroupKeys": [], + "hidden": false, + "sort": "950.1", + "outcomes": [ + { + "title": "Draw/Flint Town United & Under (1.5)", + "outcomeId": "3", + "odds": "6.13", + "point": "1.5", + "sort": "0020025150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Under (1.5)", + "outcomeId": "4", + "odds": "5.74", + "point": "1.5", + "sort": "0020015150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Flint Town United & Over (1.5)", + "outcomeId": "6", + "odds": "1.55", + "point": "1.5", + "sort": "0000015150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw & Over (1.5)", + "outcomeId": "1", + "odds": "2.12", + "point": "1.5", + "sort": "0000005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Draw/Flint Town United & Over (1.5)", + "outcomeId": "2", + "odds": "1.58", + "point": "1.5", + "sort": "0000025150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC/Draw & Under (1.5)", + "outcomeId": "5", + "odds": "7.22", + "point": "1.5", + "sort": "0020005150", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934847", + "conditionId": "600610060000000000943019940000000000001406934847", + "state": "Active", + "title": "Asian Handicap (0-0)", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 161924, + "marketVarietyId": 11921200, + "category": "handicap", + "marketGroupKeys": [], + "hidden": false, + "sort": "1245", + "outcomes": [ + { + "title": "Flint Town United (0.0)", + "outcomeId": "1", + "odds": "1.5", + "point": "0.0", + "sort": "0020005000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Trefelin FC (0.0)", + "outcomeId": "2", + "odds": "2.39", + "point": "0.0", + "sort": "0000005000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000943019940000000000001406934857", + "conditionId": "600610060000000000943019940000000000001406934857", + "state": "Active", + "title": "2nd Half - Draw No Bet", + "isExpressForbidden": false, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 12855, + "marketVarietyId": 1390076499, + "category": "winner", + "marketGroupKeys": [], + "hidden": false, + "sort": "6000.002", + "outcomes": [ + { + "title": "Trefelin FC", + "outcomeId": "2", + "odds": "2.22", + "point": null, + "sort": "0000000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + }, + { + "title": "Flint Town United", + "outcomeId": "1", + "odds": "1.55", + "point": null, + "sort": "0020000000", + "state": "Active", + "hidden": false, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000094301994", + "sport": { + "sportId": "33" + } + }, + "wonOutcomeIds": [] + } + ] +} diff --git a/tests/samples/azuro_conditions_resolved.json b/tests/samples/azuro_conditions_resolved.json new file mode 100644 index 0000000..61f80aa --- /dev/null +++ b/tests/samples/azuro_conditions_resolved.json @@ -0,0 +1,6636 @@ +{ + "conditions": [ + { + "id": "600610060000000000028666020000000000001810552036", + "conditionId": "600610060000000000028666020000000000001810552036", + "state": "Resolved", + "title": "Map 2 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215575, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00207", + "outcomes": [ + { + "title": "Under 11.5", + "outcomeId": "1", + "odds": "3.65", + "point": "11.5", + "sort": "0020006150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 11.5", + "outcomeId": "2", + "odds": "1.16", + "point": "11.5", + "sort": "0000006150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001812226478", + "conditionId": "600610060000000000028666020000000000001812226478", + "state": "Resolved", + "title": "Map 3 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189360, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00306", + "outcomes": [ + { + "title": "Under 10.5", + "outcomeId": "1", + "odds": "3.25", + "point": "10.5", + "sort": "0020006050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 10.5", + "outcomeId": "2", + "odds": "1.22", + "point": "10.5", + "sort": "0000006050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778123", + "conditionId": "600610060000000000028666020000000000001624778123", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189358, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Under 10.5", + "outcomeId": "1", + "odds": "3.65", + "point": "10.5", + "sort": "0020006050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 10.5", + "outcomeId": "2", + "odds": "1.16", + "point": "10.5", + "sort": "0000006050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001817747005", + "conditionId": "600610060000000000028666020000000000001817747005", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (-11.5)", + "outcomeId": "1", + "odds": "4.6", + "point": "-11.5", + "sort": "0000003850", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+11.5)", + "outcomeId": "2", + "odds": "1.09", + "point": "+11.5", + "sort": "0020006150", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778075", + "conditionId": "600610060000000000028666020000000000001624778075", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (-5.5)", + "outcomeId": "1", + "odds": "4.23", + "point": "-5.5", + "sort": "0000004450", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+5.5)", + "outcomeId": "2", + "odds": "1.12", + "point": "+5.5", + "sort": "0020005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001817298551", + "conditionId": "600610060000000000028666020000000000001817298551", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (-10.5)", + "outcomeId": "1", + "odds": "4.95", + "point": "-10.5", + "sort": "0000003950", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+10.5)", + "outcomeId": "2", + "odds": "1.11", + "point": "+10.5", + "sort": "0020006050", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778111", + "conditionId": "600610060000000000028666020000000000001624778111", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (-7.5)", + "outcomeId": "1", + "odds": "7.74", + "point": "-7.5", + "sort": "0000004250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+7.5)", + "outcomeId": "2", + "odds": "1", + "point": "+7.5", + "sort": "0020005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778141", + "conditionId": "600610060000000000028666020000000000001624778141", + "state": "Resolved", + "title": "Map 2 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541522, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00202", + "outcomes": [ + { + "title": "Under 17.5", + "outcomeId": "1", + "odds": "7.24", + "point": "17.5", + "sort": "0020006750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 17.5", + "outcomeId": "2", + "odds": "1", + "point": "17.5", + "sort": "0000006750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001810552030", + "conditionId": "600610060000000000028666020000000000001810552030", + "state": "Resolved", + "title": "Map 2 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215575, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00207", + "outcomes": [ + { + "title": "Under 8.5", + "outcomeId": "1", + "odds": "2.41", + "point": "8.5", + "sort": "0020005850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 8.5", + "outcomeId": "2", + "odds": "1.4", + "point": "8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001853453622", + "conditionId": "600610060000000000028666020000000000001853453622", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 1471964954, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Under 5.5", + "outcomeId": "1", + "odds": "3.36", + "point": "5.5", + "sort": "0020005550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 5.5", + "outcomeId": "2", + "odds": "1.24", + "point": "5.5", + "sort": "0000005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778135", + "conditionId": "600610060000000000028666020000000000001624778135", + "state": "Resolved", + "title": "Map 1 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541521, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00102", + "outcomes": [ + { + "title": "Under 20.5", + "outcomeId": "1", + "odds": "4.23", + "point": "20.5", + "sort": "0020007050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 20.5", + "outcomeId": "2", + "odds": "1.12", + "point": "20.5", + "sort": "0000007050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001854777570", + "conditionId": "600610060000000000028666020000000000001854777570", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 1471964954, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Over 3.5", + "outcomeId": "1", + "odds": "1.16", + "point": "3.5", + "sort": "0000005350", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 3.5", + "outcomeId": "2", + "odds": "3.65", + "point": "3.5", + "sort": "0020005350", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778035", + "conditionId": "600610060000000000028666020000000000001624778035", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (+9.5)", + "outcomeId": "1", + "odds": "1", + "point": "+9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-9.5)", + "outcomeId": "2", + "odds": "7.24", + "point": "-9.5", + "sort": "0020004050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778041", + "conditionId": "600610060000000000028666020000000000001624778041", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Metizport (+4.5)", + "outcomeId": "1", + "odds": "1.01", + "point": "+4.5", + "sort": "0020005450", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-4.5)", + "outcomeId": "2", + "odds": "6.77", + "point": "-4.5", + "sort": "0000004550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001633261381", + "conditionId": "600610060000000000028666020000000000001633261381", + "state": "Resolved", + "title": "Map 2 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189359, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00206", + "outcomes": [ + { + "title": "Under 8.5", + "outcomeId": "1", + "odds": "3.65", + "point": "8.5", + "sort": "0020005850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 8.5", + "outcomeId": "2", + "odds": "1.16", + "point": "8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001817080657", + "conditionId": "600610060000000000028666020000000000001817080657", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (-2.5)", + "outcomeId": "1", + "odds": "1.16", + "point": "-2.5", + "sort": "0020004750", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+2.5)", + "outcomeId": "2", + "odds": "3.65", + "point": "+2.5", + "sort": "0000005250", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001811389689", + "conditionId": "600610060000000000028666020000000000001811389689", + "state": "Resolved", + "title": "Map 2 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215575, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00207", + "outcomes": [ + { + "title": "Over 7.5", + "outcomeId": "1", + "odds": "1.24", + "point": "7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 7.5", + "outcomeId": "2", + "odds": "3.07", + "point": "7.5", + "sort": "0020005750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001817298549", + "conditionId": "600610060000000000028666020000000000001817298549", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (+8.5)", + "outcomeId": "1", + "odds": "1.08", + "point": "+8.5", + "sort": "0020005850", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-8.5)", + "outcomeId": "2", + "odds": "4.82", + "point": "-8.5", + "sort": "0000004150", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778051", + "conditionId": "600610060000000000028666020000000000001624778051", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (-9.5)", + "outcomeId": "1", + "odds": "9.9", + "point": "-9.5", + "sort": "0000004050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+9.5)", + "outcomeId": "2", + "odds": "1", + "point": "+9.5", + "sort": "0020005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001812226438", + "conditionId": "600610060000000000028666020000000000001812226438", + "state": "Resolved", + "title": "Map 1 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215574, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00107", + "outcomes": [ + { + "title": "Under 7.5", + "outcomeId": "1", + "odds": "3.65", + "point": "7.5", + "sort": "0020005750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 7.5", + "outcomeId": "2", + "odds": "1.16", + "point": "7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778047", + "conditionId": "600610060000000000028666020000000000001624778047", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (-7.5)", + "outcomeId": "1", + "odds": "9.9", + "point": "-7.5", + "sort": "0000004250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+7.5)", + "outcomeId": "2", + "odds": "1", + "point": "+7.5", + "sort": "0020005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778029", + "conditionId": "600610060000000000028666020000000000001624778029", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (+6.5)", + "outcomeId": "1", + "odds": "1", + "point": "+6.5", + "sort": "0000005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-6.5)", + "outcomeId": "2", + "odds": "7.24", + "point": "-6.5", + "sort": "0020004350", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778165", + "conditionId": "600610060000000000028666020000000000001624778165", + "state": "Resolved", + "title": "Map 1 - Total Rounds Odd/Even", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119755, + "marketVarietyId": 1580174544, + "category": "odd_even", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00124", + "outcomes": [ + { + "title": "Odd", + "outcomeId": "1", + "odds": "2.07", + "point": null, + "sort": "0000000000", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Even", + "outcomeId": "2", + "odds": "1.63", + "point": null, + "sort": "0010000000", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778031", + "conditionId": "600610060000000000028666020000000000001624778031", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Metizport (-7.5)", + "outcomeId": "1", + "odds": "7.74", + "point": "-7.5", + "sort": "0020004250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+7.5)", + "outcomeId": "2", + "odds": "1", + "point": "+7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778071", + "conditionId": "600610060000000000028666020000000000001624778071", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (-3.5)", + "outcomeId": "1", + "odds": "6.07", + "point": "-3.5", + "sort": "0000004650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+3.5)", + "outcomeId": "2", + "odds": "1.03", + "point": "+3.5", + "sort": "0020005350", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001817369630", + "conditionId": "600610060000000000028666020000000000001817369630", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (+5.5)", + "outcomeId": "1", + "odds": "1.26", + "point": "+5.5", + "sort": "0000005550", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-5.5)", + "outcomeId": "2", + "odds": "2.97", + "point": "-5.5", + "sort": "0020004450", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778099", + "conditionId": "600610060000000000028666020000000000001624778099", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 126736, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (-9.5)", + "outcomeId": "1", + "odds": "9.9", + "point": "-9.5", + "sort": "0020004050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+9.5)", + "outcomeId": "2", + "odds": "1", + "point": "+9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778113", + "conditionId": "600610060000000000028666020000000000001624778113", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (+8.5)", + "outcomeId": "1", + "odds": "1", + "point": "+8.5", + "sort": "0020005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-8.5)", + "outcomeId": "2", + "odds": "7.74", + "point": "-8.5", + "sort": "0000004150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001819782775", + "conditionId": "600610060000000000028666020000000000001819782775", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (+7.5)", + "outcomeId": "1", + "odds": "1.09", + "point": "+7.5", + "sort": "0020005750", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-7.5)", + "outcomeId": "2", + "odds": "4.6", + "point": "-7.5", + "sort": "0000004250", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778065", + "conditionId": "600610060000000000028666020000000000001624778065", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (-8.5)", + "outcomeId": "1", + "odds": "8.91", + "point": "-8.5", + "sort": "0020004150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+8.5)", + "outcomeId": "2", + "odds": "1.01", + "point": "+8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778025", + "conditionId": "600610060000000000028666020000000000001624778025", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (+4.5)", + "outcomeId": "1", + "odds": "1.24", + "point": "+4.5", + "sort": "0000005450", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-4.5)", + "outcomeId": "2", + "odds": "3.07", + "point": "-4.5", + "sort": "0020004550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001811389707", + "conditionId": "600610060000000000028666020000000000001811389707", + "state": "Resolved", + "title": "Map 3 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189360, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00306", + "outcomes": [ + { + "title": "Under 7.5", + "outcomeId": "1", + "odds": "3.65", + "point": "7.5", + "sort": "0020005750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 7.5", + "outcomeId": "2", + "odds": "1.16", + "point": "7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778083", + "conditionId": "600610060000000000028666020000000000001624778083", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (+9.5)", + "outcomeId": "1", + "odds": "1", + "point": "+9.5", + "sort": "0020005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-9.5)", + "outcomeId": "2", + "odds": "8.59", + "point": "-9.5", + "sort": "0000004050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778127", + "conditionId": "600610060000000000028666020000000000001624778127", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189358, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Under 12.5", + "outcomeId": "1", + "odds": "3.06", + "point": "12.5", + "sort": "0020006250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 12.5", + "outcomeId": "2", + "odds": "1.3", + "point": "12.5", + "sort": "0000006250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778091", + "conditionId": "600610060000000000028666020000000000001624778091", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (-5.5)", + "outcomeId": "1", + "odds": "7.24", + "point": "-5.5", + "sort": "0020004450", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+5.5)", + "outcomeId": "2", + "odds": "1", + "point": "+5.5", + "sort": "0000005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001817080659", + "conditionId": "600610060000000000028666020000000000001817080659", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (-4.5)", + "outcomeId": "1", + "odds": "2.01", + "point": "-4.5", + "sort": "0020004550", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+4.5)", + "outcomeId": "2", + "odds": "1.59", + "point": "+4.5", + "sort": "0000005450", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001630894711", + "conditionId": "600610060000000000028666020000000000001630894711", + "state": "Resolved", + "title": "Map 2 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189359, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00206", + "outcomes": [ + { + "title": "Under 10.5", + "outcomeId": "1", + "odds": "2.31", + "point": "10.5", + "sort": "0020006050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 10.5", + "outcomeId": "2", + "odds": "1.44", + "point": "10.5", + "sort": "0000006050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778149", + "conditionId": "600610060000000000028666020000000000001624778149", + "state": "Resolved", + "title": "Map 2 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541522, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00202", + "outcomes": [ + { + "title": "Under 21.5", + "outcomeId": "1", + "odds": "6.07", + "point": "21.5", + "sort": "0020007150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 21.5", + "outcomeId": "2", + "odds": "1.03", + "point": "21.5", + "sort": "0000007150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778033", + "conditionId": "600610060000000000028666020000000000001624778033", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Metizport (-8.5)", + "outcomeId": "1", + "odds": "7.24", + "point": "-8.5", + "sort": "0020004150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+8.5)", + "outcomeId": "2", + "odds": "1", + "point": "+8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778009", + "conditionId": "600610060000000000028666020000000000001624778009", + "state": "Resolved", + "title": "Match Winner", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119976, + "marketVarietyId": null, + "category": "winner", + "marketGroupKeys": [], + "hidden": true, + "sort": "100", + "outcomes": [ + { + "title": "Metizport", + "outcomeId": "1", + "odds": "5.58", + "point": null, + "sort": "0020000000", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports", + "outcomeId": "2", + "odds": "1.05", + "point": null, + "sort": "0000000000", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778167", + "conditionId": "600610060000000000028666020000000000001624778167", + "state": "Resolved", + "title": "Map 2 - Total Rounds Odd/Even", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119755, + "marketVarietyId": 1580174545, + "category": "odd_even", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00224", + "outcomes": [ + { + "title": "Odd", + "outcomeId": "1", + "odds": "2.07", + "point": null, + "sort": "0000000000", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Even", + "outcomeId": "2", + "odds": "1.63", + "point": null, + "sort": "0010000000", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001811389711", + "conditionId": "600610060000000000028666020000000000001811389711", + "state": "Resolved", + "title": "Map 3 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189360, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00306", + "outcomes": [ + { + "title": "Under 11.5", + "outcomeId": "2", + "odds": "4.23", + "point": "11.5", + "sort": "0020006150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 11.5", + "outcomeId": "1", + "odds": "1.12", + "point": "11.5", + "sort": "0000006150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778059", + "conditionId": "600610060000000000028666020000000000001624778059", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (+5.5)", + "outcomeId": "2", + "odds": "1.04", + "point": "+5.5", + "sort": "0000005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-5.5)", + "outcomeId": "1", + "odds": "6.93", + "point": "-5.5", + "sort": "0020004450", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778079", + "conditionId": "600610060000000000028666020000000000001624778079", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (-7.5)", + "outcomeId": "2", + "odds": "8.91", + "point": "-7.5", + "sort": "0000004250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+7.5)", + "outcomeId": "1", + "odds": "1.01", + "point": "+7.5", + "sort": "0020005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001817080669", + "conditionId": "600610060000000000028666020000000000001817080669", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (+6.5)", + "outcomeId": "1", + "odds": "1.09", + "point": "+6.5", + "sort": "0020005650", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-6.5)", + "outcomeId": "2", + "odds": "4.6", + "point": "-6.5", + "sort": "0000004350", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001817489831", + "conditionId": "600610060000000000028666020000000000001817489831", + "state": "Resolved", + "title": "Map 1 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 1471964952, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00107", + "outcomes": [ + { + "title": "Over 6.5", + "outcomeId": "1", + "odds": "1.2", + "point": "6.5", + "sort": "0000005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 6.5", + "outcomeId": "2", + "odds": "3.38", + "point": "6.5", + "sort": "0020005650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778049", + "conditionId": "600610060000000000028666020000000000001624778049", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (-8.5)", + "outcomeId": "1", + "odds": "6.07", + "point": "-8.5", + "sort": "0000004150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+8.5)", + "outcomeId": "2", + "odds": "1.03", + "point": "+8.5", + "sort": "0020005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001854206180", + "conditionId": "600610060000000000028666020000000000001854206180", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 1471964954, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Over 4.5", + "outcomeId": "1", + "odds": "1.26", + "point": "4.5", + "sort": "0000005450", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 4.5", + "outcomeId": "2", + "odds": "2.97", + "point": "4.5", + "sort": "0020005450", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001853103912", + "conditionId": "600610060000000000028666020000000000001853103912", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 1471964954, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Under 6.5", + "outcomeId": "1", + "odds": "3.26", + "point": "6.5", + "sort": "0020005650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 6.5", + "outcomeId": "2", + "odds": "1.26", + "point": "6.5", + "sort": "0000005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001811057591", + "conditionId": "600610060000000000028666020000000000001811057591", + "state": "Resolved", + "title": "Map 1 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215574, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00107", + "outcomes": [ + { + "title": "Over 10.5", + "outcomeId": "1", + "odds": "1.24", + "point": "10.5", + "sort": "0000006050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 10.5", + "outcomeId": "2", + "odds": "3.07", + "point": "10.5", + "sort": "0020006050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778115", + "conditionId": "600610060000000000028666020000000000001624778115", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (+9.5)", + "outcomeId": "1", + "odds": "1.06", + "point": "+9.5", + "sort": "0020005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-9.5)", + "outcomeId": "2", + "odds": "5.09", + "point": "-9.5", + "sort": "0000004050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778085", + "conditionId": "600610060000000000028666020000000000001624778085", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (-2.5)", + "outcomeId": "1", + "odds": "6.77", + "point": "-2.5", + "sort": "0020004750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+2.5)", + "outcomeId": "2", + "odds": "1.01", + "point": "+2.5", + "sort": "0000005250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001633261363", + "conditionId": "600610060000000000028666020000000000001633261363", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215576, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Under 10.5", + "outcomeId": "1", + "odds": "1.44", + "point": "10.5", + "sort": "0020006050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 10.5", + "outcomeId": "2", + "odds": "2.31", + "point": "10.5", + "sort": "0000006050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001811057593", + "conditionId": "600610060000000000028666020000000000001811057593", + "state": "Stopped", + "title": "Map 1 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215574, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00107", + "outcomes": [ + { + "title": "Over 11.5", + "outcomeId": "1", + "odds": "1.78", + "point": "11.5", + "sort": "0000006150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 11.5", + "outcomeId": "2", + "odds": "1.78", + "point": "11.5", + "sort": "0020006150", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001817080667", + "conditionId": "600610060000000000028666020000000000001817080667", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (-4.5)", + "outcomeId": "1", + "odds": "3.65", + "point": "-4.5", + "sort": "0000004550", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+4.5)", + "outcomeId": "2", + "odds": "1.16", + "point": "+4.5", + "sort": "0020005450", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778133", + "conditionId": "600610060000000000028666020000000000001624778133", + "state": "Resolved", + "title": "Map 1 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541521, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00102", + "outcomes": [ + { + "title": "Over 19.5", + "outcomeId": "1", + "odds": "1", + "point": "19.5", + "sort": "0000006950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 19.5", + "outcomeId": "2", + "odds": "7.24", + "point": "19.5", + "sort": "0020006950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778139", + "conditionId": "600610060000000000028666020000000000001624778139", + "state": "Resolved", + "title": "Map 1 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541521, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00102", + "outcomes": [ + { + "title": "Over 22.5", + "outcomeId": "1", + "odds": "1", + "point": "22.5", + "sort": "0000007250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 22.5", + "outcomeId": "2", + "odds": "7.74", + "point": "22.5", + "sort": "0020007250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778037", + "conditionId": "600610060000000000028666020000000000001624778037", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Metizport (+2.5)", + "outcomeId": "2", + "odds": "1.36", + "point": "+2.5", + "sort": "0020005250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-2.5)", + "outcomeId": "1", + "odds": "2.77", + "point": "-2.5", + "sort": "0000004750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001817080665", + "conditionId": "600610060000000000028666020000000000001817080665", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (+2.5)", + "outcomeId": "1", + "odds": "1.23", + "point": "+2.5", + "sort": "0020005250", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-2.5)", + "outcomeId": "2", + "odds": "3.14", + "point": "-2.5", + "sort": "0000004750", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778129", + "conditionId": "600610060000000000028666020000000000001624778129", + "state": "Resolved", + "title": "Map 1 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541521, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00102", + "outcomes": [ + { + "title": "Under 17.5", + "outcomeId": "1", + "odds": "7.24", + "point": "17.5", + "sort": "0020006750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 17.5", + "outcomeId": "2", + "odds": "1", + "point": "17.5", + "sort": "0000006750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778137", + "conditionId": "600610060000000000028666020000000000001624778137", + "state": "Resolved", + "title": "Map 1 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541521, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00102", + "outcomes": [ + { + "title": "Under 21.5", + "outcomeId": "1", + "odds": "3.07", + "point": "21.5", + "sort": "0020007150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 21.5", + "outcomeId": "2", + "odds": "1.24", + "point": "21.5", + "sort": "0000007150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001817213451", + "conditionId": "600610060000000000028666020000000000001817213451", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (+3.5)", + "outcomeId": "1", + "odds": "2.2", + "point": "+3.5", + "sort": "0000005350", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-3.5)", + "outcomeId": "2", + "odds": "1.48", + "point": "-3.5", + "sort": "0020004650", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001811057589", + "conditionId": "600610060000000000028666020000000000001811057589", + "state": "Resolved", + "title": "Map 1 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215574, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00107", + "outcomes": [ + { + "title": "Over 9.5", + "outcomeId": "1", + "odds": "1.16", + "point": "9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 9.5", + "outcomeId": "2", + "odds": "3.65", + "point": "9.5", + "sort": "0020005950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778021", + "conditionId": "600610060000000000028666020000000000001624778021", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Metizport (-2.5)", + "outcomeId": "1", + "odds": "4.95", + "point": "-2.5", + "sort": "0020004750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+2.5)", + "outcomeId": "2", + "odds": "1.11", + "point": "+2.5", + "sort": "0000005250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778119", + "conditionId": "600610060000000000028666020000000000001624778119", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189358, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Under 8.5", + "outcomeId": "1", + "odds": "3.07", + "point": "8.5", + "sort": "0020005850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 8.5", + "outcomeId": "2", + "odds": "1.24", + "point": "8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001630894693", + "conditionId": "600610060000000000028666020000000000001630894693", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215576, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Over 7.5", + "outcomeId": "1", + "odds": "1.71", + "point": "7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 7.5", + "outcomeId": "2", + "odds": "1.85", + "point": "7.5", + "sort": "0020005750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778073", + "conditionId": "600610060000000000028666020000000000001624778073", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (+4.5)", + "outcomeId": "1", + "odds": "1.03", + "point": "+4.5", + "sort": "0020005450", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-4.5)", + "outcomeId": "2", + "odds": "6.07", + "point": "-4.5", + "sort": "0000004550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778155", + "conditionId": "600610060000000000028666020000000000001624778155", + "state": "Resolved", + "title": "Map 3 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541523, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00302", + "outcomes": [ + { + "title": "Under 18.5", + "outcomeId": "1", + "odds": "7.74", + "point": "18.5", + "sort": "0020006850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 18.5", + "outcomeId": "2", + "odds": "1", + "point": "18.5", + "sort": "0000006850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778027", + "conditionId": "600610060000000000028666020000000000001624778027", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (+5.5)", + "outcomeId": "1", + "odds": "1.12", + "point": "+5.5", + "sort": "0000005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-5.5)", + "outcomeId": "2", + "odds": "4.23", + "point": "-5.5", + "sort": "0020004450", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778121", + "conditionId": "600610060000000000028666020000000000001624778121", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189358, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Over 9.5", + "outcomeId": "1", + "odds": "1.15", + "point": "9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 9.5", + "outcomeId": "2", + "odds": "3.85", + "point": "9.5", + "sort": "0020005950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001810552062", + "conditionId": "600610060000000000028666020000000000001810552062", + "state": "Resolved", + "title": "Map 2 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189359, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00206", + "outcomes": [ + { + "title": "Over 7.5", + "outcomeId": "1", + "odds": "1.16", + "point": "7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 7.5", + "outcomeId": "2", + "odds": "3.65", + "point": "7.5", + "sort": "0020005750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001630894709", + "conditionId": "600610060000000000028666020000000000001630894709", + "state": "Resolved", + "title": "Map 2 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189359, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00206", + "outcomes": [ + { + "title": "Under 9.5", + "outcomeId": "1", + "odds": "2.31", + "point": "9.5", + "sort": "0020005950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 9.5", + "outcomeId": "2", + "odds": "1.44", + "point": "9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778063", + "conditionId": "600610060000000000028666020000000000001624778063", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (+7.5)", + "outcomeId": "1", + "odds": "1.01", + "point": "+7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-7.5)", + "outcomeId": "2", + "odds": "8.91", + "point": "-7.5", + "sort": "0020004250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001817080661", + "conditionId": "600610060000000000028666020000000000001817080661", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (+6.5)", + "outcomeId": "1", + "odds": "1.09", + "point": "+6.5", + "sort": "0000005650", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-6.5)", + "outcomeId": "2", + "odds": "4.6", + "point": "-6.5", + "sort": "0020004350", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001854270433", + "conditionId": "600610060000000000028666020000000000001854270433", + "state": "Resolved", + "title": "Map 3 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 1243496220, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00302", + "outcomes": [ + { + "title": "Over 16.5", + "outcomeId": "1", + "odds": "1.15", + "point": "16.5", + "sort": "0000006650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 16.5", + "outcomeId": "2", + "odds": "3.85", + "point": "16.5", + "sort": "0020006650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778015", + "conditionId": "600610060000000000028666020000000000001624778015", + "state": "Resolved", + "title": "Map 1 - Winner", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119618, + "marketVarietyId": 2029550238, + "category": "winner", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00101", + "outcomes": [ + { + "title": "Metizport", + "outcomeId": "1", + "odds": "1.41", + "point": null, + "sort": "0020000000", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports", + "outcomeId": "2", + "odds": "2.39", + "point": null, + "sort": "0000000000", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778011", + "conditionId": "600610060000000000028666020000000000001624778011", + "state": "Resolved", + "title": "Maps Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119611, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "300", + "outcomes": [ + { + "title": "Sangal Esports (+1.5)", + "outcomeId": "1", + "odds": "1.06", + "point": "+1.5", + "sort": "0000005150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-1.5)", + "outcomeId": "2", + "odds": "5.37", + "point": "-1.5", + "sort": "0020004850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778131", + "conditionId": "600610060000000000028666020000000000001624778131", + "state": "Resolved", + "title": "Map 1 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541521, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00102", + "outcomes": [ + { + "title": "Over 18.5", + "outcomeId": "1", + "odds": "1", + "point": "18.5", + "sort": "0000006850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 18.5", + "outcomeId": "2", + "odds": "7.74", + "point": "18.5", + "sort": "0020006850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001817213455", + "conditionId": "600610060000000000028666020000000000001817213455", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (-9.5)", + "outcomeId": "1", + "odds": "5.24", + "point": "-9.5", + "sort": "0020004050", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+9.5)", + "outcomeId": "2", + "odds": "1.1", + "point": "+9.5", + "sort": "0000005950", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001811057587", + "conditionId": "600610060000000000028666020000000000001811057587", + "state": "Resolved", + "title": "Map 1 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215574, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00107", + "outcomes": [ + { + "title": "Over 8.5", + "outcomeId": "1", + "odds": "1.12", + "point": "8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 8.5", + "outcomeId": "2", + "odds": "4.23", + "point": "8.5", + "sort": "0020005850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001817080671", + "conditionId": "600610060000000000028666020000000000001817080671", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (+9.5)", + "outcomeId": "1", + "odds": "1.13", + "point": "+9.5", + "sort": "0020005950", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-9.5)", + "outcomeId": "2", + "odds": "4.75", + "point": "-9.5", + "sort": "0000004050", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001817213453", + "conditionId": "600610060000000000028666020000000000001817213453", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (+7.5)", + "outcomeId": "1", + "odds": "1.09", + "point": "+7.5", + "sort": "0000005750", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-7.5)", + "outcomeId": "2", + "odds": "4.6", + "point": "-7.5", + "sort": "0020004250", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778017", + "conditionId": "600610060000000000028666020000000000001624778017", + "state": "Resolved", + "title": "Map 2 - Winner", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119618, + "marketVarietyId": 2029550239, + "category": "winner", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00201", + "outcomes": [ + { + "title": "Sangal Esports", + "outcomeId": "1", + "odds": "1.06", + "point": null, + "sort": "0000000000", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport", + "outcomeId": "2", + "odds": "5.37", + "point": null, + "sort": "0020000000", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778169", + "conditionId": "600610060000000000028666020000000000001624778169", + "state": "Resolved", + "title": "Map 3 - Total Rounds Odd/Even", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119755, + "marketVarietyId": 1580174546, + "category": "odd_even", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00324", + "outcomes": [ + { + "title": "Even", + "outcomeId": "1", + "odds": "1.73", + "point": null, + "sort": "0010000000", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Odd", + "outcomeId": "2", + "odds": "1.93", + "point": null, + "sort": "0000000000", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778117", + "conditionId": "600610060000000000028666020000000000001624778117", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189358, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Under 7.5", + "outcomeId": "1", + "odds": "2.8", + "point": "7.5", + "sort": "0020005750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 7.5", + "outcomeId": "2", + "odds": "1.3", + "point": "7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778125", + "conditionId": "600610060000000000028666020000000000001624778125", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189358, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Under 11.5", + "outcomeId": "2", + "odds": "3.26", + "point": "11.5", + "sort": "0020006150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 11.5", + "outcomeId": "1", + "odds": "1.26", + "point": "11.5", + "sort": "0000006150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778081", + "conditionId": "600610060000000000028666020000000000001624778081", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (+8.5)", + "outcomeId": "1", + "odds": "1", + "point": "+8.5", + "sort": "0020005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-8.5)", + "outcomeId": "2", + "odds": "9.9", + "point": "-8.5", + "sort": "0000004150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001810552038", + "conditionId": "600610060000000000028666020000000000001810552038", + "state": "Resolved", + "title": "Map 2 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215575, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00207", + "outcomes": [ + { + "title": "Over 12.5", + "outcomeId": "1", + "odds": "1.16", + "point": "12.5", + "sort": "0000006250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 12.5", + "outcomeId": "2", + "odds": "3.65", + "point": "12.5", + "sort": "0020006250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778089", + "conditionId": "600610060000000000028666020000000000001624778089", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (-4.5)", + "outcomeId": "1", + "odds": "9.9", + "point": "-4.5", + "sort": "0020004550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+4.5)", + "outcomeId": "2", + "odds": "1", + "point": "+4.5", + "sort": "0000005450", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778171", + "conditionId": "600610060000000000028666020000000000001624778171", + "state": "Resolved", + "title": "Total Maps Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119975, + "marketVarietyId": null, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "200", + "outcomes": [ + { + "title": "Over 2.5", + "outcomeId": "1", + "odds": "1.06", + "point": "2.5", + "sort": "0000005250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 2.5", + "outcomeId": "2", + "odds": "5.37", + "point": "2.5", + "sort": "0020005250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778039", + "conditionId": "600610060000000000028666020000000000001624778039", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (-3.5)", + "outcomeId": "1", + "odds": "3.18", + "point": "-3.5", + "sort": "0000004650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+3.5)", + "outcomeId": "2", + "odds": "1.23", + "point": "+3.5", + "sort": "0020005350", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778043", + "conditionId": "600610060000000000028666020000000000001624778043", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Sangal Esports (-5.5)", + "outcomeId": "1", + "odds": "7.74", + "point": "-5.5", + "sort": "0000004450", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+5.5)", + "outcomeId": "2", + "odds": "1", + "point": "+5.5", + "sort": "0020005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001630894695", + "conditionId": "600610060000000000028666020000000000001630894695", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215576, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Under 8.5", + "outcomeId": "1", + "odds": "2.39", + "point": "8.5", + "sort": "0020005850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 8.5", + "outcomeId": "2", + "odds": "1.41", + "point": "8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001630894713", + "conditionId": "600610060000000000028666020000000000001630894713", + "state": "Resolved", + "title": "Map 2 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189359, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00206", + "outcomes": [ + { + "title": "Over 11.5", + "outcomeId": "1", + "odds": "2.12", + "point": "11.5", + "sort": "0000006150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 11.5", + "outcomeId": "2", + "odds": "1.52", + "point": "11.5", + "sort": "0020006150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001630894697", + "conditionId": "600610060000000000028666020000000000001630894697", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215576, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Over 9.5", + "outcomeId": "1", + "odds": "1.72", + "point": "9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 9.5", + "outcomeId": "2", + "odds": "1.84", + "point": "9.5", + "sort": "0020005950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778101", + "conditionId": "600610060000000000028666020000000000001624778101", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (-2.5)", + "outcomeId": "1", + "odds": "2.87", + "point": "-2.5", + "sort": "0000004750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+2.5)", + "outcomeId": "2", + "odds": "1.33", + "point": "+2.5", + "sort": "0020005250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001835644504", + "conditionId": "600610060000000000028666020000000000001835644504", + "state": "Resolved", + "title": "Map 2 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 1091385743, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00206", + "outcomes": [ + { + "title": "Under 6.5", + "outcomeId": "1", + "odds": "3.65", + "point": "6.5", + "sort": "0020005650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 6.5", + "outcomeId": "2", + "odds": "1.16", + "point": "6.5", + "sort": "0000005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778087", + "conditionId": "600610060000000000028666020000000000001624778087", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (-3.5)", + "outcomeId": "1", + "odds": "5.58", + "point": "-3.5", + "sort": "0020004650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+3.5)", + "outcomeId": "2", + "odds": "1.05", + "point": "+3.5", + "sort": "0000005350", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778055", + "conditionId": "600610060000000000028666020000000000001624778055", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (-3.5)", + "outcomeId": "1", + "odds": "3.06", + "point": "-3.5", + "sort": "0020004650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+3.5)", + "outcomeId": "2", + "odds": "1.3", + "point": "+3.5", + "sort": "0000005350", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778045", + "conditionId": "600610060000000000028666020000000000001624778045", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Metizport (+6.5)", + "outcomeId": "2", + "odds": "1", + "point": "+6.5", + "sort": "0020005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-6.5)", + "outcomeId": "1", + "odds": "9.9", + "point": "-6.5", + "sort": "0000004350", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001819782761", + "conditionId": "600610060000000000028666020000000000001819782761", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (-10.5)", + "outcomeId": "1", + "odds": "4.6", + "point": "-10.5", + "sort": "0020003950", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+10.5)", + "outcomeId": "2", + "odds": "1.09", + "point": "+10.5", + "sort": "0000006050", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778151", + "conditionId": "600610060000000000028666020000000000001624778151", + "state": "Resolved", + "title": "Map 2 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541522, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00202", + "outcomes": [ + { + "title": "Over 22.5", + "outcomeId": "1", + "odds": "2.69", + "point": "22.5", + "sort": "0000007250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 22.5", + "outcomeId": "2", + "odds": "1.32", + "point": "22.5", + "sort": "0020007250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778145", + "conditionId": "600610060000000000028666020000000000001624778145", + "state": "Resolved", + "title": "Map 2 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541522, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00202", + "outcomes": [ + { + "title": "Over 19.5", + "outcomeId": "1", + "odds": "1.03", + "point": "19.5", + "sort": "0000006950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 19.5", + "outcomeId": "2", + "odds": "6.07", + "point": "19.5", + "sort": "0020006950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778097", + "conditionId": "600610060000000000028666020000000000001624778097", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (+8.5)", + "outcomeId": "1", + "odds": "1.03", + "point": "+8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-8.5)", + "outcomeId": "2", + "odds": "9.9", + "point": "-8.5", + "sort": "0020004150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001630894715", + "conditionId": "600610060000000000028666020000000000001630894715", + "state": "Resolved", + "title": "Map 2 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189359, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00206", + "outcomes": [ + { + "title": "Under 12.5", + "outcomeId": "1", + "odds": "1.44", + "point": "12.5", + "sort": "0020006250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 12.5", + "outcomeId": "2", + "odds": "2.31", + "point": "12.5", + "sort": "0000006250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778013", + "conditionId": "600610060000000000028666020000000000001624778013", + "state": "Resolved", + "title": "Maps Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119611, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "300", + "outcomes": [ + { + "title": "Metizport (+1.5)", + "outcomeId": "1", + "odds": "1.11", + "point": "+1.5", + "sort": "0020005150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-1.5)", + "outcomeId": "2", + "odds": "4.29", + "point": "-1.5", + "sort": "0000004850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778095", + "conditionId": "600610060000000000028666020000000000001624778095", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (+7.5)", + "outcomeId": "2", + "odds": "1", + "point": "+7.5", + "sort": "0000005750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-7.5)", + "outcomeId": "1", + "odds": "9.9", + "point": "-7.5", + "sort": "0020004250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778019", + "conditionId": "600610060000000000028666020000000000001624778019", + "state": "Resolved", + "title": "Map 3 - Winner", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119618, + "marketVarietyId": 2029550240, + "category": "winner", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00301", + "outcomes": [ + { + "title": "Sangal Esports", + "outcomeId": "1", + "odds": "1.05", + "point": null, + "sort": "0000000000", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport", + "outcomeId": "2", + "odds": "5.58", + "point": null, + "sort": "0020000000", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778061", + "conditionId": "600610060000000000028666020000000000001624778061", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (-6.5)", + "outcomeId": "2", + "odds": "9.9", + "point": "-6.5", + "sort": "0020004350", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+6.5)", + "outcomeId": "1", + "odds": "1", + "point": "+6.5", + "sort": "0000005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001817080663", + "conditionId": "600610060000000000028666020000000000001817080663", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (+8.5)", + "outcomeId": "1", + "odds": "1.12", + "point": "+8.5", + "sort": "0000005850", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-8.5)", + "outcomeId": "2", + "odds": "4.23", + "point": "-8.5", + "sort": "0020004150", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778143", + "conditionId": "600610060000000000028666020000000000001624778143", + "state": "Resolved", + "title": "Map 2 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541522, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00202", + "outcomes": [ + { + "title": "Over 18.5", + "outcomeId": "1", + "odds": "1.02", + "point": "18.5", + "sort": "0000006850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 18.5", + "outcomeId": "2", + "odds": "7.92", + "point": "18.5", + "sort": "0020006850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778161", + "conditionId": "600610060000000000028666020000000000001624778161", + "state": "Resolved", + "title": "Map 3 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541523, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00302", + "outcomes": [ + { + "title": "Under 21.5", + "outcomeId": "1", + "odds": "2.39", + "point": "21.5", + "sort": "0020007150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 21.5", + "outcomeId": "2", + "odds": "1.41", + "point": "21.5", + "sort": "0000007150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001812226476", + "conditionId": "600610060000000000028666020000000000001812226476", + "state": "Resolved", + "title": "Map 3 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189360, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00306", + "outcomes": [ + { + "title": "Under 9.5", + "outcomeId": "1", + "odds": "3.85", + "point": "9.5", + "sort": "0020005950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 9.5", + "outcomeId": "2", + "odds": "1.15", + "point": "9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001811057595", + "conditionId": "600610060000000000028666020000000000001811057595", + "state": "Resolved", + "title": "Map 1 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215574, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00107", + "outcomes": [ + { + "title": "Under 12.5", + "outcomeId": "1", + "odds": "1.69", + "point": "12.5", + "sort": "0020006250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 12.5", + "outcomeId": "2", + "odds": "1.87", + "point": "12.5", + "sort": "0000006250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778023", + "conditionId": "600610060000000000028666020000000000001624778023", + "state": "Resolved", + "title": "Map 1 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055026, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00103", + "outcomes": [ + { + "title": "Metizport (-3.5)", + "outcomeId": "1", + "odds": "7.24", + "point": "-3.5", + "sort": "0020004650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+3.5)", + "outcomeId": "2", + "odds": "1", + "point": "+3.5", + "sort": "0000005350", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778077", + "conditionId": "600610060000000000028666020000000000001624778077", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (-6.5)", + "outcomeId": "1", + "odds": "6.07", + "point": "-6.5", + "sort": "0000004350", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+6.5)", + "outcomeId": "2", + "odds": "1.03", + "point": "+6.5", + "sort": "0020005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778057", + "conditionId": "600610060000000000028666020000000000001624778057", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (+4.5)", + "outcomeId": "2", + "odds": "1.08", + "point": "+4.5", + "sort": "0000005450", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-4.5)", + "outcomeId": "1", + "odds": "4.82", + "point": "-4.5", + "sort": "0020004550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778147", + "conditionId": "600610060000000000028666020000000000001624778147", + "state": "Resolved", + "title": "Map 2 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541522, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00202", + "outcomes": [ + { + "title": "Over 20.5", + "outcomeId": "1", + "odds": "1.12", + "point": "20.5", + "sort": "0000007050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 20.5", + "outcomeId": "2", + "odds": "4.23", + "point": "20.5", + "sort": "0020007050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778053", + "conditionId": "600610060000000000028666020000000000001624778053", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (-2.5)", + "outcomeId": "2", + "odds": "2.17", + "point": "-2.5", + "sort": "0020004750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (+2.5)", + "outcomeId": "1", + "odds": "1.56", + "point": "+2.5", + "sort": "0000005250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778069", + "conditionId": "600610060000000000028666020000000000001624778069", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Metizport (+2.5)", + "outcomeId": "1", + "odds": "1.1", + "point": "+2.5", + "sort": "0020005250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-2.5)", + "outcomeId": "2", + "odds": "4.38", + "point": "-2.5", + "sort": "0000004750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778103", + "conditionId": "600610060000000000028666020000000000001624778103", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (-3.5)", + "outcomeId": "1", + "odds": "6.77", + "point": "-3.5", + "sort": "0000004650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+3.5)", + "outcomeId": "2", + "odds": "1.01", + "point": "+3.5", + "sort": "0020005350", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001812226482", + "conditionId": "600610060000000000028666020000000000001812226482", + "state": "Resolved", + "title": "Map 3 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189360, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00306", + "outcomes": [ + { + "title": "Under 12.5", + "outcomeId": "1", + "odds": "3.65", + "point": "12.5", + "sort": "0020006250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 12.5", + "outcomeId": "2", + "odds": "1.16", + "point": "12.5", + "sort": "0000006250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001630894701", + "conditionId": "600610060000000000028666020000000000001630894701", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215576, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Over 12.5", + "outcomeId": "1", + "odds": "2.07", + "point": "12.5", + "sort": "0000006250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 12.5", + "outcomeId": "2", + "odds": "1.63", + "point": "12.5", + "sort": "0020006250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778157", + "conditionId": "600610060000000000028666020000000000001624778157", + "state": "Resolved", + "title": "Map 3 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541523, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00302", + "outcomes": [ + { + "title": "Under 19.5", + "outcomeId": "1", + "odds": "5.58", + "point": "19.5", + "sort": "0020006950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 19.5", + "outcomeId": "2", + "odds": "1.05", + "point": "19.5", + "sort": "0000006950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001630894699", + "conditionId": "600610060000000000028666020000000000001630894699", + "state": "Resolved", + "title": "Map 3 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215576, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00307", + "outcomes": [ + { + "title": "Over 11.5", + "outcomeId": "1", + "odds": "1.93", + "point": "11.5", + "sort": "0000006150", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 11.5", + "outcomeId": "2", + "odds": "1.73", + "point": "11.5", + "sort": "0020006150", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778173", + "conditionId": "600610060000000000028666020000000000001624778173", + "state": "Stopped", + "title": "Total Maps Odd/Even", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 164984, + "marketVarietyId": null, + "category": "odd_even", + "marketGroupKeys": [], + "hidden": true, + "sort": "100000", + "outcomes": [ + { + "title": "Odd", + "outcomeId": "2", + "odds": "1.13", + "point": null, + "sort": "0000000000", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Even", + "outcomeId": "1", + "odds": "5.74", + "point": null, + "sort": "0010000000", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001810552032", + "conditionId": "600610060000000000028666020000000000001810552032", + "state": "Resolved", + "title": "Map 2 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215575, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00207", + "outcomes": [ + { + "title": "Under 9.5", + "outcomeId": "1", + "odds": "3.38", + "point": "9.5", + "sort": "0020005950", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 9.5", + "outcomeId": "2", + "odds": "1.2", + "point": "9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778153", + "conditionId": "600610060000000000028666020000000000001624778153", + "state": "Resolved", + "title": "Map 3 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541523, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00302", + "outcomes": [ + { + "title": "Under 17.5", + "outcomeId": "1", + "odds": "7.74", + "point": "17.5", + "sort": "0020006750", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 17.5", + "outcomeId": "2", + "odds": "1", + "point": "17.5", + "sort": "0000006750", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001820775749", + "conditionId": "600610060000000000028666020000000000001820775749", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (+11.5)", + "outcomeId": "1", + "odds": "1.12", + "point": "+11.5", + "sort": "0000006150", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-11.5)", + "outcomeId": "2", + "odds": "4.23", + "point": "-11.5", + "sort": "0020003850", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001819991459", + "conditionId": "600610060000000000028666020000000000001819991459", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 1091385742, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Under 6.5", + "outcomeId": "1", + "odds": "4.23", + "point": "6.5", + "sort": "0020005650", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 6.5", + "outcomeId": "2", + "odds": "1.12", + "point": "6.5", + "sort": "0000005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778105", + "conditionId": "600610060000000000028666020000000000001624778105", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (-4.5)", + "outcomeId": "1", + "odds": "2.39", + "point": "-4.5", + "sort": "0000004550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+4.5)", + "outcomeId": "2", + "odds": "1.41", + "point": "+4.5", + "sort": "0020005450", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001820775689", + "conditionId": "600610060000000000028666020000000000001820775689", + "state": "Resolved", + "title": "Map 1 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 1091385742, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00106", + "outcomes": [ + { + "title": "Over 5.5", + "outcomeId": "1", + "odds": "1.28", + "point": "5.5", + "sort": "0000005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Under 5.5", + "outcomeId": "2", + "odds": "3.26", + "point": "5.5", + "sort": "0020005550", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001820795807", + "conditionId": "600610060000000000028666020000000000001820795807", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Sangal Esports (-5.5)", + "outcomeId": "1", + "odds": "4.54", + "point": "-5.5", + "sort": "0000004450", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+5.5)", + "outcomeId": "2", + "odds": "1.09", + "point": "+5.5", + "sort": "0020005550", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001810552034", + "conditionId": "600610060000000000028666020000000000001810552034", + "state": "Resolved", + "title": "Map 2 - Metizport Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120058, + "marketVarietyId": 2065215575, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00207", + "outcomes": [ + { + "title": "Under 10.5", + "outcomeId": "1", + "odds": "2.97", + "point": "10.5", + "sort": "0020006050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 10.5", + "outcomeId": "2", + "odds": "1.26", + "point": "10.5", + "sort": "0000006050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778067", + "conditionId": "600610060000000000028666020000000000001624778067", + "state": "Resolved", + "title": "Map 2 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055027, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00203", + "outcomes": [ + { + "title": "Sangal Esports (+9.5)", + "outcomeId": "1", + "odds": "1", + "point": "+9.5", + "sort": "0000005950", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-9.5)", + "outcomeId": "2", + "odds": "9.9", + "point": "-9.5", + "sort": "0020004050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001832225491", + "conditionId": "600610060000000000028666020000000000001832225491", + "state": "Stopped", + "title": "Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 158375, + "marketVarietyId": null, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "700", + "outcomes": [ + { + "title": "Metizport (+3.5)", + "outcomeId": "1", + "odds": "1.09", + "point": "+3.5", + "sort": "0020005350", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-3.5)", + "outcomeId": "2", + "odds": "4.6", + "point": "-3.5", + "sort": "0000004650", + "state": "Stopped", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [] + }, + { + "id": "600610060000000000028666020000000000001624778159", + "conditionId": "600610060000000000028666020000000000001624778159", + "state": "Resolved", + "title": "Map 3 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541523, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00302", + "outcomes": [ + { + "title": "Under 20.5", + "outcomeId": "1", + "odds": "1.85", + "point": "20.5", + "sort": "0020007050", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 20.5", + "outcomeId": "2", + "odds": "1.71", + "point": "20.5", + "sort": "0000007050", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778107", + "conditionId": "600610060000000000028666020000000000001624778107", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Metizport (+5.5)", + "outcomeId": "1", + "odds": "1.71", + "point": "+5.5", + "sort": "0020005550", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Sangal Esports (-5.5)", + "outcomeId": "2", + "odds": "1.85", + "point": "-5.5", + "sort": "0000004450", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001811389709", + "conditionId": "600610060000000000028666020000000000001811389709", + "state": "Resolved", + "title": "Map 3 - Sangal Esports Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 120024, + "marketVarietyId": 922189360, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00306", + "outcomes": [ + { + "title": "Under 8.5", + "outcomeId": "1", + "odds": "3.65", + "point": "8.5", + "sort": "0020005850", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 8.5", + "outcomeId": "2", + "odds": "1.16", + "point": "8.5", + "sort": "0000005850", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778093", + "conditionId": "600610060000000000028666020000000000001624778093", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (+6.5)", + "outcomeId": "1", + "odds": "1", + "point": "+6.5", + "sort": "0000005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (-6.5)", + "outcomeId": "2", + "odds": "7.24", + "point": "-6.5", + "sort": "0020004350", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "1" + ] + }, + { + "id": "600610060000000000028666020000000000001624778109", + "conditionId": "600610060000000000028666020000000000001624778109", + "state": "Resolved", + "title": "Map 3 - Rounds Handicap", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119752, + "marketVarietyId": 524055028, + "category": "handicap", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00303", + "outcomes": [ + { + "title": "Sangal Esports (-6.5)", + "outcomeId": "1", + "odds": "5.58", + "point": "-6.5", + "sort": "0000004350", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Metizport (+6.5)", + "outcomeId": "2", + "odds": "1.05", + "point": "+6.5", + "sort": "0020005650", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + }, + { + "id": "600610060000000000028666020000000000001624778163", + "conditionId": "600610060000000000028666020000000000001624778163", + "state": "Resolved", + "title": "Map 3 - Total Rounds Over/Under", + "isExpressForbidden": true, + "isPrematchEnabled": true, + "isLiveEnabled": true, + "margin": 0, + "marketId": 119754, + "marketVarietyId": 327541523, + "category": "total", + "marketGroupKeys": [], + "hidden": true, + "sort": "15000.00302", + "outcomes": [ + { + "title": "Under 22.5", + "outcomeId": "1", + "odds": "7.74", + "point": "22.5", + "sort": "0020007250", + "state": "Lost", + "hidden": true, + "isManuallyStopped": false + }, + { + "title": "Over 22.5", + "outcomeId": "2", + "odds": "1", + "point": "22.5", + "sort": "0000007250", + "state": "Won", + "hidden": true, + "isManuallyStopped": false + } + ], + "game": { + "gameId": "1006000000000002866602", + "sport": { + "sportId": "1061" + } + }, + "wonOutcomeIds": [ + "2" + ] + } + ] +} diff --git a/tests/samples/azuro_games.json b/tests/samples/azuro_games.json new file mode 100644 index 0000000..4378c90 --- /dev/null +++ b/tests/samples/azuro_games.json @@ -0,0 +1,408 @@ +{ + "games": [ + { + "id": "1006000000000002877243", + "gameId": "1006000000000002877243", + "slug": "mavros-oway-1006000000000002877243", + "title": "Mavros - Oway", + "startsAt": "1792772640", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "1061", + "slug": "cs2", + "name": "Counter-Strike 2", + "sporthub": { + "id": "ee5c8776-e9ad-4aa0-8334-d8392f8da16e", + "slug": "esports" + } + }, + "league": { + "id": "d834e34c-0db9-4eaf-b6e0-0e5aa4cedc7b", + "slug": "berserk-league-2026", + "name": "Berserk League 2026", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments" + }, + "participants": [ + { + "name": "Mavros", + "image": "https://avatars.azuro.org/images/1061/1006000000000002877243/Mavros.png" + }, + { + "name": "Oway", + "image": "https://avatars.azuro.org/images/1061/1006000000000002877243/Oway.png" + } + ] + }, + { + "id": "1006000000000031323517", + "gameId": "1006000000000031323517", + "slug": "noelani-sara-teso-judith-hernandez-miranda-1006000000000031323517", + "title": "Noelani Sara Teso - Judith Hernandez Miranda", + "startsAt": "1792772700", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "45", + "slug": "tennis", + "name": "Tennis", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "842a8dcc-3570-45eb-89a7-c97f698974a1", + "slug": "utr-pro-series-lubbock-women", + "name": "UTR Pro Series Lubbock - Women", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States" + }, + "participants": [ + { + "name": "Noelani Sara Teso", + "image": "https://avatars.azuro.org/images/45/1006000000000031323517/Noelani Sara Teso.png" + }, + { + "name": "Judith Hernandez Miranda", + "image": "https://avatars.azuro.org/images/45/1006000000000031323517/Judith Hernandez Miranda.png" + } + ] + }, + { + "id": "1006000000000031318901", + "gameId": "1006000000000031318901", + "slug": "jack-secord-jack-anthrop-1006000000000031318901", + "title": "Jack Secord - Jack Anthrop", + "startsAt": "1792772891", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "45", + "slug": "tennis", + "name": "Tennis", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "434b7482-a76c-4cae-b16e-d316f80b06f5", + "slug": "usa", + "name": "USA", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States" + }, + "participants": [ + { + "name": "Jack Secord", + "image": "https://avatars.azuro.org/images/45/1006000000000031318901/Jack Secord.png" + }, + { + "name": "Jack Anthrop", + "image": "https://avatars.azuro.org/images/45/1006000000000031318901/Jack Anthrop.png" + } + ] + }, + { + "id": "1006000000000094303699", + "gameId": "1006000000000094303699", + "slug": "tj-sokol-nusle-w-kladno-w-1006000000000094303699", + "title": "Tj Sokol Nusle (W) - Kladno (W)", + "startsAt": "1792773000", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "26", + "slug": "volleyball", + "name": "Volleyball", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "80299e29-e2e3-43f8-84d9-fa09e2da3059", + "slug": "1-liga-w", + "name": "1. Liga (W)", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments" + }, + "participants": [ + { + "name": "Tj Sokol Nusle (W)", + "image": "https://avatars.azuro.org/images/26/1006000000000094303699/Tj Sokol Nusle (W).png" + }, + { + "name": "Kladno (W)", + "image": "https://avatars.azuro.org/images/26/1006000000000094303699/Kladno (W).png" + } + ] + }, + { + "id": "1006000000000031272227", + "gameId": "1006000000000031272227", + "slug": "pbc-cska-moscow-bc-avtodor-saratov-1006000000000031272227", + "title": "PBC CSKA Moscow - BC Avtodor Saratov", + "startsAt": "1792773000", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "31", + "slug": "basketball", + "name": "Basketball", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "7ecb2b92-1c6a-4663-a860-1f3925cde075", + "slug": "vtb-united-league", + "name": "VTB United League", + "isTopLeague": true, + "topWeight": 0 + }, + "country": { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments" + }, + "participants": [ + { + "name": "PBC CSKA Moscow", + "image": "https://avatars.azuro.org/images/31/1006000000000031272227/PBC CSKA Moscow.png" + }, + { + "name": "BC Avtodor Saratov", + "image": "https://avatars.azuro.org/images/31/1006000000000031272227/BC Avtodor Saratov.png" + } + ] + }, + { + "id": "1006000000000094323950", + "gameId": "1006000000000094323950", + "slug": "ao-kifisias-panionios-1006000000000094323950", + "title": "AO Kifisias - Panionios", + "startsAt": "1792773000", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "26", + "slug": "volleyball", + "name": "Volleyball", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "56566746-98f9-45a8-8e0c-3bb6dbda0a32", + "slug": "a1", + "name": "A1", + "isTopLeague": true, + "topWeight": 0 + }, + "country": { + "id": "897d50f6-60d4-441b-a685-27c084190c45", + "slug": "greece", + "name": "Greece" + }, + "participants": [ + { + "name": "AO Kifisias", + "image": "https://avatars.azuro.org/images/26/1006000000000094323950/AO Kifisias.png" + }, + { + "name": "Panionios", + "image": "https://avatars.azuro.org/images/26/1006000000000094323950/Panionios.png" + } + ] + }, + { + "id": "1006000000000031306133", + "gameId": "1006000000000031306133", + "slug": "kiekko-espoo-hifk-1006000000000031306133", + "title": "Kiekko-Espoo - HIFK", + "startsAt": "1792773000", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "32", + "slug": "ice-hockey", + "name": "Ice Hockey", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "f2dd88aa-83f8-4626-a95e-8c4969735baf", + "slug": "liiga", + "name": "Liiga", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "f18b268e-1f95-4550-b562-a943f52946b9", + "slug": "finland", + "name": "Finland" + }, + "participants": [ + { + "name": "Kiekko-Espoo", + "image": "https://avatars.azuro.org/images/32/1006000000000031306133/Kiekko-Espoo.png" + }, + { + "name": "HIFK", + "image": "https://avatars.azuro.org/images/32/1006000000000031306133/HIFK.png" + } + ] + }, + { + "id": "1006000000000031256742", + "gameId": "1006000000000031256742", + "slug": "spartak-moscow-severstal-cherepovets-1006000000000031256742", + "title": "Spartak Moscow - Severstal Cherepovets", + "startsAt": "1792773000", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "32", + "slug": "ice-hockey", + "name": "Ice Hockey", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "a54d1f68-7775-4fdf-9d20-c974c7ac4bc9", + "slug": "khl", + "name": "KHL", + "isTopLeague": true, + "topWeight": 0 + }, + "country": { + "id": "3f0080c2-e76c-4acb-b555-e4052998f3fb", + "slug": "russia", + "name": "Russia" + }, + "participants": [ + { + "name": "Spartak Moscow", + "image": "https://avatars.azuro.org/images/32/1006000000000031256742/Spartak Moscow.png" + }, + { + "name": "Severstal Cherepovets", + "image": "https://avatars.azuro.org/images/32/1006000000000031256742/Severstal Cherepovets.png" + } + ] + }, + { + "id": "1006000000000031322691", + "gameId": "1006000000000031322691", + "slug": "madison-brengle-elizabeth-mandlik-1006000000000031322691", + "title": "Madison Brengle - Elizabeth Mandlik", + "startsAt": "1792773060", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "45", + "slug": "tennis", + "name": "Tennis", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "915a9a01-8a3e-487d-8278-cd7522e1b631", + "slug": "usa-w", + "name": "USA (W)", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments" + }, + "participants": [ + { + "name": "Madison Brengle", + "image": "https://avatars.azuro.org/images/45/1006000000000031322691/Madison Brengle.png" + }, + { + "name": "Elizabeth Mandlik", + "image": "https://avatars.azuro.org/images/45/1006000000000031322691/Elizabeth Mandlik.png" + } + ] + }, + { + "id": "1006000000000031318904", + "gameId": "1006000000000031318904", + "slug": "juan-manuel-la-serna-matheus-pucinelle-de-almeida-1006000000000031318904", + "title": "Juan Manuel La Serna - Matheus Pucinelle De Almeida", + "startsAt": "1792773300", + "state": "Prematch", + "turnover": "0", + "sport": { + "sportId": "45", + "slug": "tennis", + "name": "Tennis", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "869e7497-fdae-4a6e-8eaa-f06ea72e991a", + "slug": "cali", + "name": "Cali", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "924fbf5e-30ae-47e7-8cdd-35169bfc5821", + "slug": "colombia", + "name": "Colombia" + }, + "participants": [ + { + "name": "Juan Manuel La Serna", + "image": "https://avatars.azuro.org/images/45/1006000000000031318904/Juan Manuel La Serna.png" + }, + { + "name": "Matheus Pucinelle De Almeida", + "image": "https://avatars.azuro.org/images/45/1006000000000031318904/Matheus Pucinelle De Almeida.png" + } + ] + } + ], + "page": 1, + "perPage": 10, + "total": 1166, + "totalPages": 117 +} diff --git a/tests/samples/azuro_games_by_ids.json b/tests/samples/azuro_games_by_ids.json new file mode 100644 index 0000000..d6e0bb6 --- /dev/null +++ b/tests/samples/azuro_games_by_ids.json @@ -0,0 +1,84 @@ +{ + "games": [ + { + "id": "1006000000000002866602", + "gameId": "1006000000000002866602", + "slug": "sangal-esports-metizport-1006000000000002866602", + "title": "Sangal Esports - Metizport", + "startsAt": "1792687980", + "state": "Finished", + "turnover": "22", + "sport": { + "sportId": "1061", + "slug": "cs2", + "name": "Counter-Strike 2", + "sporthub": { + "id": "ee5c8776-e9ad-4aa0-8334-d8392f8da16e", + "slug": "esports" + } + }, + "league": { + "id": "caf20ffa-01fb-4f9c-b58d-35eed0fc5c4f", + "slug": "ranked-episode-6-closed-qualifier", + "name": "Ranked Episode 6: Closed Qualifier", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments" + }, + "participants": [ + { + "name": "Sangal Esports", + "image": "https://avatars.azuro.org/images/1061/1006000000000002866602/Sangal Esports.png" + }, + { + "name": "Metizport", + "image": "https://avatars.azuro.org/images/1061/1006000000000002866602/Metizport.png" + } + ] + }, + { + "id": "1006000000000094301994", + "gameId": "1006000000000094301994", + "slug": "trefelin-fc-flint-town-united-1006000000000094301994", + "title": "Trefelin FC - Flint Town United", + "startsAt": "1792781100", + "state": "Prematch", + "turnover": "87.0329", + "sport": { + "sportId": "33", + "slug": "football", + "name": "Football", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "b987f703-0250-407d-9bac-16252097ece2", + "slug": "cymru-premier", + "name": "Cymru Premier", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "c139e7c4-77ea-47e4-853e-6e4c82b1eab8", + "slug": "wales", + "name": "Wales" + }, + "participants": [ + { + "name": "Trefelin FC", + "image": "https://avatars.azuro.org/images/33/1006000000000094301994/Trefelin FC.png" + }, + { + "name": "Flint Town United", + "image": "https://avatars.azuro.org/images/33/1006000000000094301994/Flint Town United.png" + } + ] + } + ] +} diff --git a/tests/samples/azuro_navigation.json b/tests/samples/azuro_navigation.json new file mode 100644 index 0000000..61b844f --- /dev/null +++ b/tests/samples/azuro_navigation.json @@ -0,0 +1,4050 @@ +{ + "sports": [ + { + "id": 44, + "slug": "american-football", + "name": "American Football", + "sportId": "44", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 14, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 14, + "countries": [ + { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States", + "turnover": "1836.88", + "activeGamesCount": 14, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 14, + "leagues": [ + { + "id": "ba24572e-de14-4d7b-a081-0b02a2ba9b26", + "slug": "nfl", + "name": "NFL", + "isTopLeague": true, + "topWeight": 0, + "turnover": "1836.88", + "activeGamesCount": 14, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 14 + } + ] + } + ] + }, + { + "id": 28, + "slug": "baseball", + "name": "Baseball", + "sportId": "28", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "countries": [ + { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States", + "turnover": "3", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "3a4486af-5ec9-4320-9241-880bdad69b26", + "slug": "mlb", + "name": "MLB", + "isTopLeague": true, + "topWeight": 0, + "turnover": "3", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + } + ] + }, + { + "id": 31, + "slug": "basketball", + "name": "Basketball", + "sportId": "31", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 198, + "activeLiveGamesCount": 12, + "activePrematchGamesCount": 186, + "countries": [ + { + "id": "853b5565-e225-4fa9-aba6-ceb1cf23f1f7", + "slug": "argentina", + "name": "Argentina", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "8df3a1f5-216a-4f1c-b1e0-b107467bef64", + "slug": "la-liga-argentina", + "name": "La Liga Argentina", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "30019433-f902-4983-9340-85dc596dca69", + "slug": "la-liga-femenina-w", + "name": "La Liga Femenina (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "dd04e44b-b511-4311-9efb-7e370c528f6b", + "slug": "australia", + "name": "Australia", + "turnover": "20.9", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "3a3dfe70-d7b6-4c16-a207-33fd22a51cc8", + "slug": "nbl", + "name": "NBL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + }, + { + "id": "d1e597d9-24b1-4d41-b561-28ccd0d6363c", + "slug": "wnbl", + "name": "WNBL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "20.9", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "3b5ce713-eeab-4f54-83c4-adac6afc2e70", + "slug": "austria", + "name": "Austria", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "ee97f0f8-1848-4299-ae26-dee88013d718", + "slug": "bsl", + "name": "BSL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "a3c2a398-5364-4976-b4db-24c2d9cd91ce", + "slug": "brazil", + "name": "Brazil", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "42161f83-b388-424a-88fe-d75245e28ad3", + "slug": "nbb", + "name": "NBB", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "35908e41-5b17-43d7-813f-eed39803978a", + "slug": "chile", + "name": "Chile", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "6ca80faf-41d9-48ad-9a12-505d61765974", + "slug": "lnb", + "name": "LNB", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "0485dc68-767d-499f-94a8-a9dedff7e571", + "slug": "denmark", + "name": "Denmark", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "eff95c23-fe61-4659-8661-1dc97073fa57", + "slug": "basketligaen", + "name": "Basketligaen", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "f18b268e-1f95-4550-b562-a943f52946b9", + "slug": "finland", + "name": "Finland", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 4, + "leagues": [ + { + "id": "fa77d1a7-d1c7-44ed-be70-ca9bc373ac26", + "slug": "korisliiga", + "name": "Korisliiga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "4c43eaf4-4a7f-44da-b826-88dfc47e4786", + "slug": "france", + "name": "France", + "turnover": "4.1911538461538465", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "842f679e-97b7-4920-9620-33f92dc4fd43", + "slug": "lfb-w", + "name": "LFB (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "734bd340-947d-48a0-8b60-43d7a0d94f7f", + "slug": "pro-a", + "name": "Pro A", + "isTopLeague": true, + "topWeight": 0, + "turnover": "4.1911538461538465", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "0441c8e8-5fad-43f5-8ae6-50a468a7e339", + "slug": "germany", + "name": "Germany", + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8, + "leagues": [ + { + "id": "c8e7fd8d-2610-4391-af53-372e7b4b03a7", + "slug": "bbl", + "name": "BBL", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "897d50f6-60d4-441b-a685-27c084190c45", + "slug": "greece", + "name": "Greece", + "turnover": "0", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10, + "leagues": [ + { + "id": "ba65e5c7-5bdb-4c8a-8c41-e44f3cdcbda3", + "slug": "a1-w", + "name": "A1 (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + }, + { + "id": "900cc868-8739-488e-9ec4-d8bde105effd", + "slug": "basket-league", + "name": "Basket League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "f03059af-3e1d-4acc-8734-3e50137b1a81", + "slug": "hungary", + "name": "Hungary", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "88f32d6d-bac5-4cec-99d9-4c55ef471f0e", + "slug": "nb-i", + "name": "NB I", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "749fafaf-1c48-45a7-a9f1-ef7fd37825ef", + "slug": "iceland", + "name": "Iceland", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "4ea62ad2-325f-4951-b1bd-1684745175c8", + "slug": "premier-league", + "name": "Premier League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "130.4", + "activeGamesCount": 33, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 31, + "leagues": [ + { + "id": "f687f40f-a786-4b08-889e-5cbc20ef4ffa", + "slug": "aba-league", + "name": "ABA League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 9, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 8 + }, + { + "id": "019fe386-69c1-4f12-b4e0-60ed9f9e0f6d", + "slug": "bnxt-league", + "name": "BNXT League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + }, + { + "id": "6b0c8e95-9a2c-4b82-ad90-33ffc92ef445", + "slug": "euroleague", + "name": "Euroleague", + "isTopLeague": true, + "topWeight": 0, + "turnover": "130.4", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "5e1a4ecc-c9cd-479d-a5ab-7fe89f4285dd", + "slug": "latvian-estonian-basketball-league", + "name": "Latvian-Estonian Basketball League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + }, + { + "id": "77574c61-520e-412b-b44f-18de4ed31336", + "slug": "nbl", + "name": "NBL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3 + }, + { + "id": "7ecb2b92-1c6a-4663-a860-1f3925cde075", + "slug": "vtb-united-league", + "name": "VTB United League", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "33126e3b-218b-43d7-9cc0-154de6fd513d", + "slug": "wbla-w", + "name": "WBLA (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "151cb52c-f1f5-4192-a58e-5f4e41498d84", + "slug": "israel", + "name": "Israel", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "c3d11fc6-81e2-43f0-b48d-b7ae922d612b", + "slug": "super-league", + "name": "Super League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "c7b3f0f8-226d-4245-8d33-7f03b74e21a2", + "slug": "italy", + "name": "Italy", + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8, + "leagues": [ + { + "id": "6377823e-a0f7-49d0-81b3-8e0606743a67", + "slug": "serie-a", + "name": "Serie A", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "40becd45-fc59-4b19-be19-955cc30f7f2d", + "slug": "japan", + "name": "Japan", + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8, + "leagues": [ + { + "id": "8200ddcb-d9d5-4f93-98d2-524cd8beeeaa", + "slug": "b-league-1", + "name": "B League 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "6f1d2aa0-f4fe-4401-ae92-0fb92ceed3ed", + "slug": "lithuania", + "name": "Lithuania", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4, + "leagues": [ + { + "id": "19041b09-13db-4b5d-8922-b8f688ca5987", + "slug": "lkl", + "name": "LKL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "e1d5fa76-2be7-4582-a842-4ad9e7c9bc9d", + "slug": "mexico", + "name": "Mexico", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "5175f16b-3564-48da-a67c-23c835545b21", + "slug": "lnbp", + "name": "LNBP", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "d7ad6904-7b23-446c-813f-94290862bdae", + "slug": "norway", + "name": "Norway", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "5ceded3c-4fa8-4a7e-ac56-9c668d099d25", + "slug": "blno", + "name": "BLNO", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "8920d050-f45f-447a-80f3-6b2f44195962", + "slug": "poland", + "name": "Poland", + "turnover": "0", + "activeGamesCount": 18, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 17, + "leagues": [ + { + "id": "c5fa14d9-102e-46f8-b669-c3c97b4fcaa1", + "slug": "1-liga", + "name": "1. liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "5da97143-4b04-4d7a-a8f4-74d48a75751b", + "slug": "plk", + "name": "PLK", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + }, + { + "id": "e942f8c8-15fe-4776-8067-b8ae94fecf18", + "slug": "plkk-w", + "name": "PLKK (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "7956532e-afb7-42c1-9bbf-74d582ca3aef", + "slug": "portugal", + "name": "Portugal", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "0a5457f2-2673-44bb-b6e3-83761d0bdae6", + "slug": "nacional-liga-w", + "name": "Nacional Liga (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "203697c9-a099-4980-82ef-e4048da47c0a", + "slug": "slovakia", + "name": "Slovakia", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4, + "leagues": [ + { + "id": "79f9fbee-676f-4100-b599-50751e254ea7", + "slug": "extraliga", + "name": "Extraliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "e75ae69b-6965-410b-a2cc-d66d1c5fb610", + "slug": "south-korea", + "name": "South Korea", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "eef22a0b-0002-4983-abf7-57f47cfa07c2", + "slug": "kbl", + "name": "KBL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "7269aa71-860e-4cca-b8b0-e04721c87bcc", + "slug": "spain", + "name": "Spain", + "turnover": "0", + "activeGamesCount": 13, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 13, + "leagues": [ + { + "id": "849ad84f-5c94-4951-90cd-bba70eba95a3", + "slug": "liga-acb", + "name": "Liga ACB", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "1183d63b-ccb0-4e79-9721-991d4e8be63e", + "slug": "liga-femenina-w", + "name": "Liga Femenina (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "45082afd-17b1-4d06-83ec-337e63560e6a", + "slug": "sweden", + "name": "Sweden", + "turnover": "116.61", + "activeGamesCount": 8, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "4b0d27b0-2811-4982-8b61-17f2a9c9e047", + "slug": "basketligan", + "name": "Basketligan", + "isTopLeague": false, + "topWeight": 0, + "turnover": "116.61", + "activeGamesCount": 5, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 4 + }, + { + "id": "fe16bbad-cb21-4aa4-aa28-66247ed621e5", + "slug": "superettan", + "name": "Superettan", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "c8fafa99-9151-42e0-bcd8-0bda2fdcc3e5", + "slug": "switzerland", + "name": "Switzerland", + "turnover": "0", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10, + "leagues": [ + { + "id": "b98e7cdb-7fff-425b-9595-fa9be5ea82fb", + "slug": "nlb", + "name": "NLB", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + }, + { + "id": "caf29460-d752-422e-acec-237c57ac246b", + "slug": "sbl", + "name": "SBL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "5589328e-fce3-4ff5-8d32-dcd3c8cdeb39", + "slug": "turkey", + "name": "Turkey", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "55872476-b726-412b-9633-b1881aa3a7e9", + "slug": "tbsl", + "name": "TBSL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "4849c873-916e-4675-a21f-8bc649c7b75f", + "slug": "ukraine", + "name": "Ukraine", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 4, + "activePrematchGamesCount": 0, + "leagues": [ + { + "id": "f7c88302-8e9d-4780-82b1-5dbaa1f186a8", + "slug": "super-league", + "name": "Super League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 4, + "activePrematchGamesCount": 0 + } + ] + }, + { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States", + "turnover": "1", + "activeGamesCount": 13, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 13, + "leagues": [ + { + "id": "a633c662-a34a-4234-b775-466cf86be8e8", + "slug": "nba", + "name": "NBA", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 12, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 12 + }, + { + "id": "37812e1f-c61f-45e9-a7e5-7749a875bb87", + "slug": "wnba-w", + "name": "WNBA (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "1", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "f1f0ec7b-59a5-4377-b788-f1c1c32008e8", + "slug": "uruguay", + "name": "Uruguay", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "b69a59fd-7917-4f24-92d1-4f3eeb30e416", + "slug": "lub", + "name": "LUB", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + } + ] + }, + { + "id": 29, + "slug": "boxing", + "name": "Boxing", + "sportId": "29", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 25, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 25, + "countries": [ + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 25, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 25, + "leagues": [ + { + "id": "3261f3ed-9e7c-45b9-9eb6-b1c0dffa11a6", + "slug": "matchups", + "name": "Matchups", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 25, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 25 + } + ] + } + ] + }, + { + "id": 40, + "slug": "cricket", + "name": "Cricket", + "sportId": "40", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "countries": [ + { + "id": "cf80f0dc-265e-4da7-90c1-d08159bbe7b9", + "slug": "canada", + "name": "Canada", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "3cee76cf-a71c-4cf5-9e10-8d1c89051d01", + "slug": "ncl-gt20", + "name": "NCL GT20", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4, + "leagues": [ + { + "id": "4dce7f63-770e-43ae-a2e4-942fe687b8a7", + "slug": "one-day-international-matches", + "name": "One Day International Matches", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "b21087a7-4b87-4295-ac38-68559403f2da", + "slug": "t20-world-cup-sub-regional-africa-qualifiers-c", + "name": "T20 World Cup Sub Regional Africa Qualifiers C", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + } + ] + }, + { + "id": 33, + "slug": "football", + "name": "Football", + "sportId": "33", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 636, + "activeLiveGamesCount": 21, + "activePrematchGamesCount": 615, + "countries": [ + { + "id": "b1b31c3c-6e29-4c87-b48c-942001ec1d13", + "slug": "algeria", + "name": "Algeria", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "ed1dce85-79f5-4617-8f72-5b41aedeca53", + "slug": "ligue-1", + "name": "Ligue 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "853b5565-e225-4fa9-aba6-ceb1cf23f1f7", + "slug": "argentina", + "name": "Argentina", + "turnover": "41.81", + "activeGamesCount": 16, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 16, + "leagues": [ + { + "id": "775a7bbe-0047-48ad-94ae-43a9fdd8c95f", + "slug": "copa-argentina", + "name": "Copa Argentina", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "835cdd41-d657-42ca-b033-30dbaa8d4945", + "slug": "liga-profesional", + "name": "Liga Profesional", + "isTopLeague": false, + "topWeight": 0, + "turnover": "41.81", + "activeGamesCount": 15, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 15 + } + ] + }, + { + "id": "dd04e44b-b511-4311-9efb-7e370c528f6b", + "slug": "australia", + "name": "Australia", + "turnover": "51.05", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "41fc2547-0413-4a16-a4e1-50191e27c4b3", + "slug": "a-league", + "name": "A-League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "51.05", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "3b5ce713-eeab-4f54-83c4-adac6afc2e70", + "slug": "austria", + "name": "Austria", + "turnover": "10.461538461538462", + "activeGamesCount": 14, + "activeLiveGamesCount": 5, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "00da57b0-01bc-4d21-90d9-9d7257773916", + "slug": "2-liga", + "name": "2. Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "1", + "activeGamesCount": 8, + "activeLiveGamesCount": 5, + "activePrematchGamesCount": 3 + }, + { + "id": "acc90094-de4c-418d-a4d5-de6bd0299a6c", + "slug": "bundesliga", + "name": "Bundesliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "9.461538461538462", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "a3c2a398-5364-4976-b4db-24c2d9cd91ce", + "slug": "brazil", + "name": "Brazil", + "turnover": "68.49958564298187", + "activeGamesCount": 18, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 18, + "leagues": [ + { + "id": "43aa89c9-7fa0-49a1-8fa2-8525a73d6fd5", + "slug": "brasileirao-serie-a", + "name": "Brasileirao - Serie A", + "isTopLeague": false, + "topWeight": 0, + "turnover": "68.49958564298187", + "activeGamesCount": 18, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 18 + } + ] + }, + { + "id": "5b952254-77fe-43f9-bb97-495953a891f8", + "slug": "bulgaria", + "name": "Bulgaria", + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "c11f4bb8-a4ef-403e-8526-4481b72e146b", + "slug": "efbet-liga", + "name": "Efbet Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "35908e41-5b17-43d7-813f-eed39803978a", + "slug": "chile", + "name": "Chile", + "turnover": "65", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8, + "leagues": [ + { + "id": "9e766672-3a5e-4c44-a399-5a9280866888", + "slug": "primera-division", + "name": "Primera Division", + "isTopLeague": false, + "topWeight": 0, + "turnover": "65", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "28bf5a71-8d57-4145-a0cb-e2592101e481", + "slug": "china", + "name": "China", + "turnover": "0", + "activeGamesCount": 16, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 16, + "leagues": [ + { + "id": "8c416799-ddb5-4617-a7b4-c3e461b5d5b2", + "slug": "league-1", + "name": "League 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "556147f2-87af-4a08-a5e0-9b6aae93c207", + "slug": "super-league", + "name": "Super League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "340f594e-8978-4b34-82bd-64623a4088c4", + "slug": "croatia", + "name": "Croatia", + "turnover": "1", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "8ec73b56-3d1e-46f4-a8c6-3ed8b5992844", + "slug": "hnl", + "name": "HNL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "1", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "b723d6f8-bdb7-4722-840c-653816c948cf", + "slug": "cyprus", + "name": "Cyprus", + "turnover": "1", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "894f46b9-7209-469b-9b4f-cbae133f168b", + "slug": "1st-division", + "name": "1st Division", + "isTopLeague": false, + "topWeight": 0, + "turnover": "1", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "0485dc68-767d-499f-94a8-a9dedff7e571", + "slug": "denmark", + "name": "Denmark", + "turnover": "2", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "17ca5e99-2226-4670-b2cf-31ef948f83b9", + "slug": "superliga", + "name": "Superliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "2", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "28c94203-3958-41e9-8e80-b7791236d625", + "slug": "egypt", + "name": "Egypt", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "ccb393fb-7067-44f8-a016-b8c68927ee68", + "slug": "premier-league", + "name": "Premier League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "0aed7fe4-3bc1-4ca4-ad52-dfd2b188d9ad", + "slug": "england", + "name": "England", + "turnover": "246.10876068556647", + "activeGamesCount": 116, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 116, + "leagues": [ + { + "id": "cbbc4945-da8e-4759-bc21-c2ee7ab59b78", + "slug": "championship", + "name": "Championship", + "isTopLeague": false, + "topWeight": 0, + "turnover": "50.51527883880825", + "activeGamesCount": 12, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 12 + }, + { + "id": "45bb710f-70d2-4b31-bc53-a0c3e4b5d860", + "slug": "efl-cup", + "name": "EFL Cup", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "62c5d6e8-435a-485a-a9c6-90b0748e8e45", + "slug": "fa-trophy", + "name": "FA Trophy", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 27, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 27 + }, + { + "id": "76a2f309-bc47-4ba4-9398-5e34b77b4c96", + "slug": "isthmian-league-premier-division", + "name": "Isthmian League - Premier Division", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + }, + { + "id": "cfb7a9c3-2f11-4036-ac8c-6dd9d3e06f1c", + "slug": "league-one", + "name": "League One", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 13, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 13 + }, + { + "id": "4205696c-6765-4df9-86be-5658b8bcce2d", + "slug": "league-two", + "name": "League Two", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 13, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 13 + }, + { + "id": "1ee7ce1a-9d8c-49aa-88a3-385008161393", + "slug": "national-league", + "name": "National League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "36", + "activeGamesCount": 11, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 11 + }, + { + "id": "23930505-d196-4e92-8875-8cdb725e2b32", + "slug": "national-league-south", + "name": "National League South", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 12, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 12 + }, + { + "id": "c2e8dd40-8b06-4171-80c6-8c99a9ad1326", + "slug": "premier-league", + "name": "Premier League", + "isTopLeague": true, + "topWeight": 0, + "turnover": "157.59348184675818", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10 + }, + { + "id": "f6e2d469-8d21-4859-b62d-76796c887d4b", + "slug": "super-league-w", + "name": "Super League (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "2", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "8220f8bf-bd9a-4f5c-8fe0-19eb94521f28", + "slug": "estonia", + "name": "Estonia", + "turnover": "0", + "activeGamesCount": 9, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "40a700c1-c8c1-4185-80a2-72a1a97a69eb", + "slug": "premium-liiga", + "name": "Premium Liiga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 9, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "f18b268e-1f95-4550-b562-a943f52946b9", + "slug": "finland", + "name": "Finland", + "turnover": "5", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "8e5433f4-febc-410c-b3df-dc558714294f", + "slug": "veikkausliiga", + "name": "Veikkausliiga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "5", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "4c43eaf4-4a7f-44da-b826-88dfc47e4786", + "slug": "france", + "name": "France", + "turnover": "87.85724275724276", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "e18b6c55-6d7f-49ac-b9a5-8d59a6bba0ab", + "slug": "ligue-1", + "name": "Ligue 1", + "isTopLeague": true, + "topWeight": 0, + "turnover": "87.85724275724276", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9 + } + ] + }, + { + "id": "0441c8e8-5fad-43f5-8ae6-50a468a7e339", + "slug": "germany", + "name": "Germany", + "turnover": "81.62668463223274", + "activeGamesCount": 18, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 16, + "leagues": [ + { + "id": "6c4d74c5-6ed9-440a-854c-8f67753081ee", + "slug": "2-bundesliga", + "name": "2. Bundesliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "5", + "activeGamesCount": 9, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 7 + }, + { + "id": "279a60eb-c1f4-41f6-86de-39e40e93dd02", + "slug": "bundesliga", + "name": "Bundesliga", + "isTopLeague": true, + "topWeight": 0, + "turnover": "76.62668463223274", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9 + } + ] + }, + { + "id": "897d50f6-60d4-441b-a685-27c084190c45", + "slug": "greece", + "name": "Greece", + "turnover": "4", + "activeGamesCount": 15, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 15, + "leagues": [ + { + "id": "b3303bba-6cba-4328-b9c0-99f7914820ba", + "slug": "greek-cup", + "name": "Greek Cup", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "7b87fc28-4a52-40af-9b02-f1e70e4d19a8", + "slug": "super-league", + "name": "Super League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "4", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "f03059af-3e1d-4acc-8734-3e50137b1a81", + "slug": "hungary", + "name": "Hungary", + "turnover": "4", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "5da3245b-da75-42be-b701-460ac0091a99", + "slug": "nb-i", + "name": "NB I", + "isTopLeague": false, + "topWeight": 0, + "turnover": "4", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "272f2ea0-688e-4a92-8ae7-1216159c812a", + "slug": "india", + "name": "India", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "fcb00dbc-521a-4597-9901-b37aaeed1053", + "slug": "indian-super-league", + "name": "Indian Super League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "29", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8, + "leagues": [ + { + "id": "c82d4e0c-6235-4cc0-95c5-da3d2353c915", + "slug": "1-liga", + "name": "1. Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "29", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "5cc9325b-0380-438c-b682-82a8aadc2542", + "slug": "ireland", + "name": "Ireland", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "ac897bde-7918-4d16-a6aa-9438c478983d", + "slug": "premier-division", + "name": "Premier Division", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "151cb52c-f1f5-4192-a58e-5f4e41498d84", + "slug": "israel", + "name": "Israel", + "turnover": "0", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "21d7d31d-dfb2-4cf6-9fb8-355bfc54e033", + "slug": "liga-alef", + "name": "Liga Alef", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "8adff4b2-f1d2-43cb-ba37-6526dc8bddc3", + "slug": "liga-bet", + "name": "Liga Bet", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "e01064a9-8d7a-4bc0-9b9b-ab8142e97e6b", + "slug": "premier-league", + "name": "Premier League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "c7b3f0f8-226d-4245-8d33-7f03b74e21a2", + "slug": "italy", + "name": "Italy", + "turnover": "45.879265091863516", + "activeGamesCount": 36, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 36, + "leagues": [ + { + "id": "084b2fb1-5879-443b-9509-4b27d7a283d0", + "slug": "serie-a", + "name": "Serie A", + "isTopLeague": true, + "topWeight": 0, + "turnover": "16.879265091863516", + "activeGamesCount": 20, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 20 + }, + { + "id": "2f7f0c56-9576-494d-b6d5-428da9835118", + "slug": "serie-a-w", + "name": "Serie A (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + }, + { + "id": "9bedbd6b-2714-4316-aa9c-df7cb511ab25", + "slug": "serie-b", + "name": "Serie B", + "isTopLeague": false, + "topWeight": 0, + "turnover": "29", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10 + } + ] + }, + { + "id": "40becd45-fc59-4b19-be19-955cc30f7f2d", + "slug": "japan", + "name": "Japan", + "turnover": "0", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10, + "leagues": [ + { + "id": "df0727f8-cd4c-48f8-af36-5dca6447108e", + "slug": "j1-100-year-vision-league", + "name": "J1 100 Year Vision League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10 + } + ] + }, + { + "id": "cef9c76c-e8e2-406f-b715-57680bd585be", + "slug": "jordan", + "name": "Jordan", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 4, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "4cb30ed0-58da-4e39-8495-db688683e0d1", + "slug": "division-1", + "name": "Division 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 3, + "activePrematchGamesCount": 0 + }, + { + "id": "35952d85-e563-41e7-842f-5ab9a0b821ae", + "slug": "pro-league", + "name": "Pro League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "3dc6789f-53ac-442e-a275-e72ae1271f9e", + "slug": "kazakhstan", + "name": "Kazakhstan", + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "e0db2a4a-7ec8-426b-8bc2-3d289813072b", + "slug": "premier-league", + "name": "Premier League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "38fd0d2a-0b5c-4cce-863e-50bb99e6d111", + "slug": "kuwait", + "name": "Kuwait", + "turnover": "427.88", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "4f7df1c2-07db-4c11-a9e3-41877739728b", + "slug": "premier-league", + "name": "Premier League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "427.88", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "6f1d2aa0-f4fe-4401-ae92-0fb92ceed3ed", + "slug": "lithuania", + "name": "Lithuania", + "turnover": "0", + "activeGamesCount": 12, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 11, + "leagues": [ + { + "id": "6c43ca39-fbca-4c47-a4af-8c4927161a00", + "slug": "1-lyga", + "name": "1 Lyga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 7 + }, + { + "id": "f24085df-1f0c-4ab6-8670-4bf2409772c4", + "slug": "a-lyga", + "name": "A Lyga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "e1d5fa76-2be7-4582-a842-4ad9e7c9bc9d", + "slug": "mexico", + "name": "Mexico", + "turnover": "1", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "25a27b64-caa5-4678-a432-73df4e7a1baa", + "slug": "liga-mx", + "name": "Liga MX", + "isTopLeague": false, + "topWeight": 0, + "turnover": "1", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9 + } + ] + }, + { + "id": "f859abc9-b740-4e4d-a024-fa52f2d9a6b0", + "slug": "montenegro", + "name": "Montenegro", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "2499a3e6-d6ae-4498-9d25-c3bfdbc6fbef", + "slug": "first-league", + "name": "First League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "239e3828-5894-4803-bb53-2915c4c31c21", + "slug": "morocco", + "name": "Morocco", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "e0b68f6c-a93c-42b7-8c01-a1f7cb1a08e4", + "slug": "botola", + "name": "Botola", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "99ec4325-038a-4cab-b1cf-eb3c5f4d5e63", + "slug": "netherlands", + "name": "Netherlands", + "turnover": "5.846153846153847", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "74a307a9-32eb-4017-837d-16389fb916be", + "slug": "eredivisie", + "name": "Eredivisie", + "isTopLeague": true, + "topWeight": 0, + "turnover": "5.846153846153847", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9 + } + ] + }, + { + "id": "6aa7d5e8-326c-4ce4-b0d6-6a6b7d78d903", + "slug": "northern-ireland", + "name": "Northern Ireland", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "599a5957-28a9-4765-b733-bc1f5b383d06", + "slug": "premiership", + "name": "Premiership", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "d7ad6904-7b23-446c-813f-94290862bdae", + "slug": "norway", + "name": "Norway", + "turnover": "5", + "activeGamesCount": 16, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 16, + "leagues": [ + { + "id": "6c4a516d-3f98-40eb-909c-43d80b15361a", + "slug": "1st-division", + "name": "1st Division", + "isTopLeague": false, + "topWeight": 0, + "turnover": "3", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "a26de6b2-ab55-4522-9184-f663c2ae9827", + "slug": "eliteserien", + "name": "Eliteserien", + "isTopLeague": false, + "topWeight": 0, + "turnover": "2", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "83066ae1-3929-4f13-a601-d0c32e292de2", + "slug": "paraguay", + "name": "Paraguay", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "2291a4d8-cd08-4b0d-a180-7b911ff32072", + "slug": "primera-division", + "name": "Primera Division", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "fcdff780-6793-4166-b3ad-9bf0370a55c9", + "slug": "peru", + "name": "Peru", + "turnover": "2", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "14b23ef9-ff14-421a-bdaa-d3e1946376af", + "slug": "liga-1", + "name": "Liga 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "2", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9 + } + ] + }, + { + "id": "8920d050-f45f-447a-80f3-6b2f44195962", + "slug": "poland", + "name": "Poland", + "turnover": "64", + "activeGamesCount": 18, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 16, + "leagues": [ + { + "id": "41ecb4c7-fe38-4ac7-996c-69af470e2aa9", + "slug": "ekstraklasa", + "name": "Ekstraklasa", + "isTopLeague": false, + "topWeight": 0, + "turnover": "64", + "activeGamesCount": 9, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 8 + }, + { + "id": "9bffe986-6e9d-44b1-bdad-2090bc91d75f", + "slug": "i-liga", + "name": "I Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 9, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "7956532e-afb7-42c1-9bbf-74d582ca3aef", + "slug": "portugal", + "name": "Portugal", + "turnover": "106.15", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "4f2c698c-5c7d-42dc-a49e-950850884426", + "slug": "primeira-liga", + "name": "Primeira Liga", + "isTopLeague": true, + "topWeight": 0, + "turnover": "106.15", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9 + } + ] + }, + { + "id": "9e17e126-1624-454a-b981-7d8d6789f7e4", + "slug": "romania", + "name": "Romania", + "turnover": "31", + "activeGamesCount": 8, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "0c5876d8-f507-4505-bf1a-e2a627eb3116", + "slug": "liga-i", + "name": "Liga I", + "isTopLeague": false, + "topWeight": 0, + "turnover": "31", + "activeGamesCount": 8, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "5f36af5f-ed27-4da6-a4d4-cac2dab75650", + "slug": "saudi-arabia", + "name": "Saudi Arabia", + "turnover": "22.880000000000003", + "activeGamesCount": 8, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "565c1d0e-c05d-4bed-9d1b-98b06dce8358", + "slug": "professional-league", + "name": "Professional League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "22.880000000000003", + "activeGamesCount": 8, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "077c1454-f7e4-4f5b-811e-b8062445d2d2", + "slug": "scotland", + "name": "Scotland", + "turnover": "2", + "activeGamesCount": 27, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 27, + "leagues": [ + { + "id": "be393edf-fd62-4595-8c27-f7c2fff8dcf2", + "slug": "league-one", + "name": "League One", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + }, + { + "id": "e1e7cfcb-68ee-4963-9381-86ccba00dfc1", + "slug": "premiership", + "name": "Premiership", + "isTopLeague": false, + "topWeight": 0, + "turnover": "2", + "activeGamesCount": 12, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 12 + }, + { + "id": "73df4981-8a5a-4c64-a9d5-3773ece11f51", + "slug": "scottish-cup", + "name": "Scottish Cup", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10 + } + ] + }, + { + "id": "bee9bbc7-0551-48f1-b7f1-f4c014a7a727", + "slug": "serbia", + "name": "Serbia", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "c0dff242-624f-426d-a74c-f3b803a28839", + "slug": "prva-liga", + "name": "Prva Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "58b97c41-0641-4edc-b6ee-284def410775", + "slug": "slovenia", + "name": "Slovenia", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "7211069a-0a0b-498e-a2f3-4365c16b98c9", + "slug": "prvaliga", + "name": "PrvaLiga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "e75ae69b-6965-410b-a2cc-d66d1c5fb610", + "slug": "south-korea", + "name": "South Korea", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "4980afb8-8fe0-4702-b20c-a80a753bc1c0", + "slug": "k-league-1", + "name": "K League 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "7269aa71-860e-4cca-b8b0-e04721c87bcc", + "slug": "spain", + "name": "Spain", + "turnover": "59.66374482555345", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10, + "leagues": [ + { + "id": "0c4edf34-0f92-4d28-8110-164fc6251a79", + "slug": "laliga", + "name": "LaLiga", + "isTopLeague": true, + "topWeight": 0, + "turnover": "59.66374482555345", + "activeGamesCount": 10, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 10 + } + ] + }, + { + "id": "45082afd-17b1-4d06-83ec-337e63560e6a", + "slug": "sweden", + "name": "Sweden", + "turnover": "0", + "activeGamesCount": 37, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 37, + "leagues": [ + { + "id": "73ce5c99-8d7c-4011-bbfc-3e5175ead660", + "slug": "allsvenskan", + "name": "Allsvenskan", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + }, + { + "id": "93606bb2-7e3a-4264-a246-7f1f92011a82", + "slug": "elitettan-w", + "name": "Elitettan (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + }, + { + "id": "93774944-eaa2-4c02-843c-334558e03768", + "slug": "ettan", + "name": "Ettan", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 15, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 15 + }, + { + "id": "59ced72a-c9a1-4bf8-8e87-f13c42d32199", + "slug": "superettan", + "name": "Superettan", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "c8fafa99-9151-42e0-bcd8-0bda2fdcc3e5", + "slug": "switzerland", + "name": "Switzerland", + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "024039a7-1167-46c5-a311-816b67275107", + "slug": "promotion-league", + "name": "Promotion League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "b426c09f-16f6-431c-9c50-76ac502adfe6", + "slug": "super-league", + "name": "Super League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + }, + { + "id": "f8b364a7-b096-4f58-b411-47e9ae8c7657", + "slug": "thailand", + "name": "Thailand", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4, + "leagues": [ + { + "id": "b963bae7-2038-44c1-a1bd-88f4b6b9b660", + "slug": "thai-league-1", + "name": "Thai League 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "5589328e-fce3-4ff5-8d32-dcd3c8cdeb39", + "slug": "turkey", + "name": "Turkey", + "turnover": "9.675287356321839", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9, + "leagues": [ + { + "id": "5604602c-ec93-446b-8b04-b4f45022a534", + "slug": "super-lig", + "name": "Super Lig", + "isTopLeague": false, + "topWeight": 0, + "turnover": "9.675287356321839", + "activeGamesCount": 9, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 9 + } + ] + }, + { + "id": "afd90409-818b-4590-9a80-2ed5a4165537", + "slug": "united-arab-emirates", + "name": "United Arab Emirates", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "e38af9ee-7b9b-427b-b300-508a77225220", + "slug": "pro-league", + "name": "Pro League", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States", + "turnover": "0", + "activeGamesCount": 15, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 15, + "leagues": [ + { + "id": "9597daa0-103c-4ddd-9973-5f7cc64f3429", + "slug": "mls", + "name": "MLS", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 15, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 15 + } + ] + }, + { + "id": "f1f0ec7b-59a5-4377-b788-f1c1c32008e8", + "slug": "uruguay", + "name": "Uruguay", + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "2c2e3c0b-a70d-4aa8-b799-a7fc1e98fe61", + "slug": "segunda-division", + "name": "Segunda Division", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "1310ae52-2149-45e6-8fbe-cc0fab0426fe", + "slug": "vietnam", + "name": "Vietnam", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "d751b623-2a76-4cfb-a1fa-3e68eb635ed8", + "slug": "vleague-1", + "name": "V.League 1", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "c139e7c4-77ea-47e4-853e-6e4c82b1eab8", + "slug": "wales", + "name": "Wales", + "turnover": "109.8629", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8, + "leagues": [ + { + "id": "b987f703-0250-407d-9bac-16252097ece2", + "slug": "cymru-premier", + "name": "Cymru Premier", + "isTopLeague": false, + "topWeight": 0, + "turnover": "109.8629", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + } + ] + }, + { + "id": 30, + "slug": "formula-1", + "name": "Formula 1", + "sportId": "30", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3, + "countries": [ + { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "632e47b9-78f4-4e0e-a53b-5af848dd9919", + "slug": "practice", + "name": "Practice", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "74950742-ad65-4a82-9649-c51db99ef01d", + "slug": "qualifying", + "name": "Qualifying", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "3710f012-1ab7-468e-aa07-b3fed5198a18", + "slug": "race", + "name": "Race", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + } + ] + }, + { + "id": 27, + "slug": "handball", + "name": "Handball", + "sportId": "27", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 17, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 17, + "countries": [ + { + "id": "3b5ce713-eeab-4f54-83c4-adac6afc2e70", + "slug": "austria", + "name": "Austria", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "0b028018-01b9-4f7a-b821-7978a9673cfe", + "slug": "hla", + "name": "HLA", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "4c43eaf4-4a7f-44da-b826-88dfc47e4786", + "slug": "france", + "name": "France", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "a54cd7e3-01dc-4248-88ea-55d90260871f", + "slug": "starligue", + "name": "Starligue", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "0441c8e8-5fad-43f5-8ae6-50a468a7e339", + "slug": "germany", + "name": "Germany", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "c94071a9-f646-46f3-8682-14e4921fd6b0", + "slug": "bundesliga", + "name": "Bundesliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + }, + { + "id": "35b80cf3-9a8f-49b7-ad90-efde62a38551", + "slug": "bundesliga-2", + "name": "Bundesliga 2", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "749fafaf-1c48-45a7-a9f1-ef7fd37825ef", + "slug": "iceland", + "name": "Iceland", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "675eb08d-23c9-4f75-9f28-84cd05833f97", + "slug": "olis-deildin", + "name": "Olis deildin", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "8920d050-f45f-447a-80f3-6b2f44195962", + "slug": "poland", + "name": "Poland", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "c93aafb2-748b-4367-b064-da7f02196299", + "slug": "1-liga", + "name": "1 Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "bee9bbc7-0551-48f1-b7f1-f4c014a7a727", + "slug": "serbia", + "name": "Serbia", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "5c7ebcf5-d239-4feb-978c-41a9d84e6aa7", + "slug": "superliga", + "name": "Superliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "7269aa71-860e-4cca-b8b0-e04721c87bcc", + "slug": "spain", + "name": "Spain", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "39c6c9a3-919a-4b96-bfad-0856c9f0bc9e", + "slug": "asobal", + "name": "Asobal", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + } + ] + }, + { + "id": 32, + "slug": "ice-hockey", + "name": "Ice Hockey", + "sportId": "32", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 105, + "activeLiveGamesCount": 13, + "activePrematchGamesCount": 92, + "countries": [ + { + "id": "f18b268e-1f95-4550-b562-a943f52946b9", + "slug": "finland", + "name": "Finland", + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 4, + "activePrematchGamesCount": 4, + "leagues": [ + { + "id": "f2dd88aa-83f8-4626-a95e-8c4969735baf", + "slug": "liiga", + "name": "Liiga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 4, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "0441c8e8-5fad-43f5-8ae6-50a468a7e339", + "slug": "germany", + "name": "Germany", + "turnover": "0", + "activeGamesCount": 14, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 14, + "leagues": [ + { + "id": "c7ef4c80-37f3-4760-92c5-e8e2ef02e7cd", + "slug": "del", + "name": "DEL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + }, + { + "id": "0d172eee-7b2b-4908-8177-0e05efccdf99", + "slug": "del-2", + "name": "DEL 2", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 19, + "activeLiveGamesCount": 6, + "activePrematchGamesCount": 13, + "leagues": [ + { + "id": "a75d1aae-48b5-4a6b-bc20-9472a875a854", + "slug": "extraliga", + "name": "Extraliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 19, + "activeLiveGamesCount": 6, + "activePrematchGamesCount": 13 + } + ] + }, + { + "id": "3f0080c2-e76c-4acb-b555-e4052998f3fb", + "slug": "russia", + "name": "Russia", + "turnover": "58.92999999999999", + "activeGamesCount": 15, + "activeLiveGamesCount": 3, + "activePrematchGamesCount": 12, + "leagues": [ + { + "id": "a54d1f68-7775-4fdf-9d20-c974c7ac4bc9", + "slug": "khl", + "name": "KHL", + "isTopLeague": true, + "topWeight": 0, + "turnover": "58.92999999999999", + "activeGamesCount": 15, + "activeLiveGamesCount": 3, + "activePrematchGamesCount": 12 + } + ] + }, + { + "id": "45082afd-17b1-4d06-83ec-337e63560e6a", + "slug": "sweden", + "name": "Sweden", + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "d91079f1-6606-46b9-95eb-dbdd73b35d5e", + "slug": "shl", + "name": "SHL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + }, + { + "id": "c8fafa99-9151-42e0-bcd8-0bda2fdcc3e5", + "slug": "switzerland", + "name": "Switzerland", + "turnover": "0", + "activeGamesCount": 13, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 13, + "leagues": [ + { + "id": "dbb9b103-f3a5-4210-91f6-7fc3f6d8921f", + "slug": "nla", + "name": "NLA", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 13, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 13 + } + ] + }, + { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States", + "turnover": "0", + "activeGamesCount": 29, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 29, + "leagues": [ + { + "id": "0cb6b5d0-e1b3-40bf-966c-e31c32d91a39", + "slug": "ahl", + "name": "AHL", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 15, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 15 + }, + { + "id": "6f7eb3f8-f0e2-4286-a6bc-3c27c9b0d484", + "slug": "nhl", + "name": "NHL", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 14, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 14 + } + ] + } + ] + }, + { + "id": 36, + "slug": "mma", + "name": "MMA", + "sportId": "36", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 36, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 36, + "countries": [ + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 36, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 36, + "leagues": [ + { + "id": "a5ed7aa3-032f-4a6e-b642-79c6e7b56660", + "slug": "matchups", + "name": "Matchups", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 22, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 22 + }, + { + "id": "3a80c089-849d-4fb4-a6dc-df6f09471997", + "slug": "ufc-333-volkanovski-vs-evloev", + "name": "UFC 333 - Volkanovski vs Evloev", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 14, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 14 + } + ] + } + ] + }, + { + "id": 58, + "slug": "rugby-league", + "name": "Rugby League", + "sportId": "58", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "countries": [ + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "994ab911-2497-44e4-8193-576cdf3edb30", + "slug": "world-cup", + "name": "World Cup", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4 + }, + { + "id": "51241de9-de76-4453-80ec-63a7c3e294b3", + "slug": "world-cup-women", + "name": "World Cup - Women", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + } + ] + } + ] + }, + { + "id": 59, + "slug": "rugby-union", + "name": "Rugby Union", + "sportId": "59", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "countries": [ + { + "id": "4c43eaf4-4a7f-44da-b826-88dfc47e4786", + "slug": "france", + "name": "France", + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "d084e526-f16f-4eb1-b0fe-c38f5d273359", + "slug": "top-14", + "name": "Top 14", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 7, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 7 + } + ] + } + ] + }, + { + "id": 34, + "slug": "snooker", + "name": "Snooker", + "sportId": "34", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "countries": [ + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "c7947535-6c8b-45e2-a00e-f797bfaafa04", + "slug": "northern-ireland-open", + "name": "Northern Ireland Open", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + } + ] + }, + { + "id": 1, + "slug": "table-tennis", + "name": "Table Tennis", + "sportId": "1", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 6, + "activeLiveGamesCount": 6, + "activePrematchGamesCount": 0, + "countries": [ + { + "id": "853b5565-e225-4fa9-aba6-ceb1cf23f1f7", + "slug": "argentina", + "name": "Argentina", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0, + "leagues": [ + { + "id": "b3fd9939-0e68-4280-b7c6-9e6fc9bb6863", + "slug": "masters", + "name": "Masters", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + } + ] + }, + { + "id": "8920d050-f45f-447a-80f3-6b2f44195962", + "slug": "poland", + "name": "Poland", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 3, + "activePrematchGamesCount": 0, + "leagues": [ + { + "id": "6e2284fe-9f1f-4046-a603-b5e374d6f460", + "slug": "masters", + "name": "Masters", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 3, + "activePrematchGamesCount": 0 + } + ] + }, + { + "id": "7269aa71-860e-4cca-b8b0-e04721c87bcc", + "slug": "spain", + "name": "Spain", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 0, + "leagues": [ + { + "id": "254e8e34-5437-4263-8a11-5949aa5f6194", + "slug": "masters", + "name": "Masters", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 0 + } + ] + } + ] + }, + { + "id": 45, + "slug": "tennis", + "name": "Tennis", + "sportId": "45", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 76, + "activeLiveGamesCount": 13, + "activePrematchGamesCount": 63, + "countries": [ + { + "id": "dd04e44b-b511-4311-9efb-7e370c528f6b", + "slug": "australia", + "name": "Australia", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "76b6f866-49a7-47f0-8762-bcedffbad434", + "slug": "australia-w", + "name": "Australia (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "0af42bd8-0df1-410c-999e-c5c102d4f9ba", + "slug": "belgium", + "name": "Belgium", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "2fd6ff1b-e16f-4fd8-bcf4-608137a82a4c", + "slug": "brussels", + "name": "Brussels", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 1 + }, + { + "id": "e4f2ad36-14b1-4132-83b0-49e31997e180", + "slug": "brussels-doubles", + "name": "Brussels - Doubles", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "28bf5a71-8d57-4145-a0cb-e2592101e481", + "slug": "china", + "name": "China", + "turnover": "0", + "activeGamesCount": 16, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 16, + "leagues": [ + { + "id": "db2c3140-4b24-48e6-8055-842e17d2fa74", + "slug": "guangzhou", + "name": "Guangzhou", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "6c203e37-4c21-4783-9447-e60cdd9ba655", + "slug": "guangzhou-w", + "name": "Guangzhou (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 12, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 12 + }, + { + "id": "813ecdcc-82b4-4921-8b35-3e08a93e2fe0", + "slug": "ningbo-doubles-w", + "name": "Ningbo - Doubles (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "a5f7a0f4-ecd8-4861-80a7-7a07850b07b0", + "slug": "ningbo-w", + "name": "Ningbo (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "924fbf5e-30ae-47e7-8cdd-35169bfc5821", + "slug": "colombia", + "name": "Colombia", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "869e7497-fdae-4a6e-8eaa-f06ea72e991a", + "slug": "cali", + "name": "Cali", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "4c43eaf4-4a7f-44da-b826-88dfc47e4786", + "slug": "france", + "name": "France", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "5841f791-279b-486f-aa14-1aa9671aa660", + "slug": "atp-lyon-hard-double", + "name": "ATP Lyon - Hard (Double)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "3c9bc995-daf5-4bcc-b575-ee038e217b63", + "slug": "france", + "name": "France", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + }, + { + "id": "b570bd2a-2530-46c7-9146-b285dacfb172", + "slug": "france-w", + "name": "France (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + }, + { + "id": "70e12e99-3d2a-4a30-86f4-ddd13d1ff81b", + "slug": "lyon", + "name": "Lyon", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 7, + "leagues": [ + { + "id": "3c26819b-312f-4aba-a6a8-6e0d6709f405", + "slug": "australia", + "name": "Australia", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "1198b992-53b3-4929-b56b-eff5c8f6b78a", + "slug": "brussels", + "name": "Brussels", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "43732f09-884d-4803-9e32-49d273f1e42c", + "slug": "guangzhou", + "name": "Guangzhou", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "4a5df1f8-6369-4408-8286-d4120760a1a3", + "slug": "lyon", + "name": "Lyon", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + }, + { + "id": "564cee7b-dafc-484c-b0bb-fc6d2757f979", + "slug": "tokyo-w", + "name": "Tokyo (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 3 + }, + { + "id": "915a9a01-8a3e-487d-8278-cd7522e1b631", + "slug": "usa-w", + "name": "USA (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "c7b3f0f8-226d-4245-8d33-7f03b74e21a2", + "slug": "italy", + "name": "Italy", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0, + "leagues": [ + { + "id": "e4bc8418-4ee1-4672-8a62-b81aef26b1e8", + "slug": "atp-challenger-florence-clay-doubles", + "name": "ATP Challenger Florence - Clay (Doubles)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + } + ] + }, + { + "id": "40becd45-fc59-4b19-be19-955cc30f7f2d", + "slug": "japan", + "name": "Japan", + "turnover": "0", + "activeGamesCount": 11, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 11, + "leagues": [ + { + "id": "2837ae01-3503-4f5c-b0c0-6a1edcec28ff", + "slug": "osaka-doubles-w", + "name": "Osaka - Doubles (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "18975490-938d-451b-ad66-fe333a764d28", + "slug": "osaka-w", + "name": "Osaka (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + }, + { + "id": "3c0f789f-c38d-43e3-81e4-c29fbe54670c", + "slug": "tokyo-w", + "name": "Tokyo (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8 + } + ] + }, + { + "id": "3dc6789f-53ac-442e-a275-e72ae1271f9e", + "slug": "kazakhstan", + "name": "Kazakhstan", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "f471c5e9-a52d-41e7-9bd5-f51dabe86a68", + "slug": "almaty", + "name": "Almaty", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 1 + }, + { + "id": "8e8be8a4-870c-4aa8-b5b8-f5e12cb1e440", + "slug": "almaty-doubles", + "name": "Almaty - Doubles", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "4dfd33d7-e5b9-4b18-bc12-417a48d24007", + "slug": "united-states", + "name": "United States", + "turnover": "0", + "activeGamesCount": 23, + "activeLiveGamesCount": 6, + "activePrematchGamesCount": 17, + "leagues": [ + { + "id": "255b0687-873f-4069-8b31-a28e503f490c", + "slug": "atp-challenger-fort-worth-hard-doubles", + "name": "ATP Challenger Fort Worth - Hard (Doubles)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "c8d88906-b4c6-4650-ac84-a9e8895350fd", + "slug": "fort-worth", + "name": "Fort Worth", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3 + }, + { + "id": "434b7482-a76c-4cae-b16e-d316f80b06f5", + "slug": "usa", + "name": "USA", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 1 + }, + { + "id": "2d435bd8-1c20-4568-a1d4-b5c9368c8c7c", + "slug": "usa-w", + "name": "USA (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + }, + { + "id": "e9c8ae78-ea25-490e-ae98-4869d38491f4", + "slug": "utr-pro-series-lubbock", + "name": "UTR Pro Series Lubbock", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 6 + }, + { + "id": "842a8dcc-3570-45eb-89a7-c97f698974a1", + "slug": "utr-pro-series-lubbock-women", + "name": "UTR Pro Series Lubbock - Women", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + } + ] + } + ] + }, + { + "id": 26, + "slug": "volleyball", + "name": "Volleyball", + "sportId": "26", + "sportHub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + }, + "activeGamesCount": 55, + "activeLiveGamesCount": 7, + "activePrematchGamesCount": 48, + "countries": [ + { + "id": "853b5565-e225-4fa9-aba6-ceb1cf23f1f7", + "slug": "argentina", + "name": "Argentina", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "102d1af3-bc00-4323-bffc-fdb9b17286a4", + "slug": "division-de-honor-w", + "name": "Division de Honor (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "a3c2a398-5364-4976-b4db-24c2d9cd91ce", + "slug": "brazil", + "name": "Brazil", + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6, + "leagues": [ + { + "id": "09ead89a-838c-4120-a2d4-27908fec17c3", + "slug": "paulista-u21", + "name": "Paulista U21", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "b5fe9ef5-6ec4-492b-be66-53ea4bdd7228", + "slug": "superliga-w", + "name": "SuperLiga (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "b723d6f8-bdb7-4722-840c-653816c948cf", + "slug": "cyprus", + "name": "Cyprus", + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2, + "leagues": [ + { + "id": "55d999d9-59d2-4f17-9ca7-89ab69d9451f", + "slug": "league-cup", + "name": "League Cup", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 2 + } + ] + }, + { + "id": "f18b268e-1f95-4550-b562-a943f52946b9", + "slug": "finland", + "name": "Finland", + "turnover": "0", + "activeGamesCount": 3, + "activeLiveGamesCount": 3, + "activePrematchGamesCount": 0, + "leagues": [ + { + "id": "3ed478b3-04f3-4331-ac2e-2b1ee4fb8d7c", + "slug": "mestaruusliiga", + "name": "Mestaruusliiga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 2, + "activePrematchGamesCount": 0 + }, + { + "id": "c4ca66e9-bbdb-4452-a163-0bf7bd6cc7a7", + "slug": "mestaruusliiga-w", + "name": "Mestaruusliiga (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + } + ] + }, + { + "id": "4c43eaf4-4a7f-44da-b826-88dfc47e4786", + "slug": "france", + "name": "France", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "78d4f9ee-6793-4f87-b630-c9d81469b5fe", + "slug": "ligue-a", + "name": "Ligue A", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "0441c8e8-5fad-43f5-8ae6-50a468a7e339", + "slug": "germany", + "name": "Germany", + "turnover": "0", + "activeGamesCount": 8, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 8, + "leagues": [ + { + "id": "b85cd48a-f62b-48f7-8498-b9854f5e8e76", + "slug": "1-bundesliga", + "name": "1. Bundesliga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4 + }, + { + "id": "2097047f-8cf8-4b5e-bbde-d8cb15126074", + "slug": "1-bundesliga-w", + "name": "1. Bundesliga (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "897d50f6-60d4-441b-a685-27c084190c45", + "slug": "greece", + "name": "Greece", + "turnover": "0", + "activeGamesCount": 11, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 10, + "leagues": [ + { + "id": "56566746-98f9-45a8-8e0c-3bb6dbda0a32", + "slug": "a1", + "name": "A1", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 6, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 6 + }, + { + "id": "a23f8034-bf82-44de-a068-64370f4a0c00", + "slug": "a1-w", + "name": "A1 (W)", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 4 + } + ] + }, + { + "id": "699286d6-12ac-42f2-9fd8-06d55eaf20ff", + "slug": "international-tournaments", + "name": "International Tournaments", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "6fe768b3-9452-482b-bb25-ee46d6d37a41", + "slug": "1-liga", + "name": "1. Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 2, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 1 + }, + { + "id": "80299e29-e2e3-43f8-84d9-fa09e2da3059", + "slug": "1-liga-w", + "name": "1. Liga (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + }, + { + "id": "419df95f-129b-4e51-9d41-5304a7afc0aa", + "slug": "extraliga-w", + "name": "Extraliga (W)", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "c7b3f0f8-226d-4245-8d33-7f03b74e21a2", + "slug": "italy", + "name": "Italy", + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5, + "leagues": [ + { + "id": "6c741bc0-3577-46f9-95a4-19644ff5a40c", + "slug": "superlega", + "name": "SuperLega", + "isTopLeague": true, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 5, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 5 + } + ] + }, + { + "id": "99ec4325-038a-4cab-b1cf-eb3c5f4d5e63", + "slug": "netherlands", + "name": "Netherlands", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1, + "leagues": [ + { + "id": "f352cc28-d2d2-4ce7-988d-65cf537733fe", + "slug": "eredivisie", + "name": "Eredivisie", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 0, + "activePrematchGamesCount": 1 + } + ] + }, + { + "id": "8920d050-f45f-447a-80f3-6b2f44195962", + "slug": "poland", + "name": "Poland", + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3, + "leagues": [ + { + "id": "dc670a82-2d8e-45d6-a9a1-8ac5ed5b3cb1", + "slug": "i-liga", + "name": "I. Liga", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 4, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 3 + } + ] + }, + { + "id": "5589328e-fce3-4ff5-8d32-dcd3c8cdeb39", + "slug": "turkey", + "name": "Turkey", + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0, + "leagues": [ + { + "id": "94351c4e-778b-4ad4-9702-f85067d1afd7", + "slug": "1-lig", + "name": "1. Lig", + "isTopLeague": false, + "topWeight": 0, + "turnover": "0", + "activeGamesCount": 1, + "activeLiveGamesCount": 1, + "activePrematchGamesCount": 0 + } + ] + } + ] + } + ] +} diff --git a/tests/samples/azuro_search.json b/tests/samples/azuro_search.json new file mode 100644 index 0000000..0cefbf8 --- /dev/null +++ b/tests/samples/azuro_search.json @@ -0,0 +1,44 @@ +{ + "games": [ + { + "id": "1006000000000094301994", + "gameId": "1006000000000094301994", + "slug": "trefelin-fc-flint-town-united-1006000000000094301994", + "title": "Trefelin FC - Flint Town United", + "startsAt": "1792781100", + "state": "Prematch", + "turnover": "87.0329", + "sport": { + "sportId": "33", + "slug": "football", + "name": "Football", + "sporthub": { + "id": "f800a0de-5fb6-45ff-8ed2-35daab086119", + "slug": "sports" + } + }, + "league": { + "id": "b987f703-0250-407d-9bac-16252097ece2", + "slug": "cymru-premier", + "name": "Cymru Premier", + "isTopLeague": false, + "topWeight": 0 + }, + "country": { + "id": "c139e7c4-77ea-47e4-853e-6e4c82b1eab8", + "slug": "wales", + "name": "Wales" + }, + "participants": [ + { + "name": "Trefelin FC", + "image": "https://avatars.azuro.org/images/33/1006000000000094301994/Trefelin FC.png" + }, + { + "name": "Flint Town United", + "image": "https://avatars.azuro.org/images/33/1006000000000094301994/Flint Town United.png" + } + ] + } + ] +} diff --git a/tests/samples/azuro_signing_vectors.json b/tests/samples/azuro_signing_vectors.json new file mode 100644 index 0000000..6b7b8c6 --- /dev/null +++ b/tests/samples/azuro_signing_vectors.json @@ -0,0 +1,253 @@ +{ + "source": "@azuro-org/toolkit 7.2.0 getBetTypedData / getCashoutTypedData, viem 2.37.4 hashTypedData and signTypedData", + "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "cases": [ + { + "name": "polygon single", + "chainId": 137, + "clientData": { + "attention": "By signing this transaction, I agree to place a bet for an event on Azuro Protocol", + "affiliate": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "core": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", + "expiresAt": 1792780300, + "chainId": 137, + "relayerFeeAmount": "12345", + "isBetSponsored": false, + "isFeeSponsored": false, + "isSponsoredBetReturnable": false + }, + "bet": { + "conditionId": "600610060000000000943019940000000000001406934671", + "outcomeId": "3", + "minOdds": "6830000000000", + "amount": "5000000", + "nonce": "1792780000000" + }, + "domain": { + "name": "Live Betting", + "version": "1.0.0", + "chainId": 137, + "verifyingContract": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7" + }, + "digest": "0x616f4bffef951dad0757e70d94fa880db0d841cbadbd70a3d1e6ed1b228386f1", + "signature": "0xb18b917c40a89c64ea83554facbf980cd3eaa1b450635157bdedc2e33759daac6cd43f21646fecb423297d59459bb93bf2592534f59cbd24f344bea14f6acf971c" + }, + { + "name": "base single", + "chainId": 8453, + "clientData": { + "attention": "By signing this transaction, I agree to place a bet for an event on Azuro Protocol", + "affiliate": "0x1111111111111111111111111111111111111111", + "core": "0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d", + "expiresAt": 1792780300, + "chainId": 8453, + "relayerFeeAmount": "12345", + "isBetSponsored": false, + "isFeeSponsored": false, + "isSponsoredBetReturnable": false + }, + "bet": { + "conditionId": "600610060000000000028772180000000000002040246119", + "outcomeId": "1", + "minOdds": "1351351351352", + "amount": "1000000000000000", + "nonce": "42" + }, + "domain": { + "name": "Live Betting", + "version": "1.0.0", + "chainId": 8453, + "verifyingContract": "0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d" + }, + "digest": "0x4dc53f4e9beb140765a7e67ad3872f04585c28abd786a845d94456c70a0329ab", + "signature": "0xf5c9774d590e97347d2539c6d7b621f2973f2db593930c6b1f17c6419273d19177f7920aa07d6e8c53da4162e81ec3b9c97740705d98561ed9fd4de1847028651b" + } + ], + "cashout": { + "chainId": 137, + "tokenId": "472063", + "cashoutOdds": "1.85", + "expiredAt": 1792780300, + "attention": "By signing this transaction, I agree to cash out on Azuro", + "domain": { + "name": "Cash Out", + "version": "1.0.0", + "chainId": 137, + "verifyingContract": "0x4a2BB4211cCF9b9eA6eF01D0a61448154ED19095" + }, + "message": { + "attention": "By signing this transaction, I agree to cash out on Azuro", + "chainId": "137", + "items": [ + { + "betId": "472063", + "bettingContract": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", + "minOdds": "1850000000000" + } + ], + "expiresAt": "1792780300" + }, + "digest": "0xbdd331f876933fc5471a31745fb8dedd43b01e8f6a855557a503e2e5e4910726", + "signature": "0x99fb3f81cba4617aafdbec03e4e1cc43647cd3e9d8d0ba1a4e7d28daf192987f0f9ead5a191e0a74bc7bf718d3a9cedf4252404279f307daa13feac8b4d2f55a1b" + }, + "min_odds": [ + { + "odds": 1.36, + "slippage": 0, + "minOdds": "1.360000000000" + }, + { + "odds": 1.36, + "slippage": 5, + "minOdds": "1.340000000000" + }, + { + "odds": 2.77, + "slippage": 10, + "minOdds": "2.590000000000" + }, + { + "odds": 6.83, + "slippage": 2.5, + "minOdds": "6.680000000000" + } + ], + "chains": { + "56": { + "environment": "BscUSDT", + "core": "0x14CB339E65f2AbF69941FeA56916Faee092f25ad", + "relayer": "0xAe684d68526a8d6a1c6426848d787ef060346030", + "lp": "0xa2db60D3DcecEc80d5D30c680a7b1e2018cD909f", + "cashout": "0x81F72B93ABaf061535aaF5D831F05e0CC4084b32", + "betToken": { + "address": "0x55d398326f99059ff775485246999027b3197955", + "symbol": "USDT", + "decimals": 18 + }, + "api": "https://api.onchainfeed.org/api/v1/public", + "socket": "wss://streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-bsc-v3" + }, + "97": { + "environment": "BscDevUSDT", + "core": "0x524994dcA5EA2bc979ac5506E7195F28B4c16932", + "relayer": "0x779e12C9a864640f7Ee4e16414e85925b33a2505", + "lp": "0x431A0993d29eEb0fF7e3FE351A303eF72195431a", + "cashout": "0x61E1b1b73cfc748AA82f686E2ec1E59c8e3e6431", + "betToken": { + "address": "0x99B127C373D500F25Ee22b591835Dc1D4DF83CeD", + "symbol": "USDT", + "decimals": 6 + }, + "api": "https://dev-api.onchainfeed.org/api/v1/public", + "socket": "wss://dev-streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-bsc-dev-v3" + }, + "100": { + "environment": "GnosisXDAI", + "core": "0x0e00E6ffcb5E207B800eec9e02999D1Fd9e448f2", + "relayer": "0x40eb85018fA18dDd10a433C8c40ca8D6744d8B32", + "lp": "0xeb7cDA87D00d677A6Dc73EB569723b0fA51D97E7", + "cashout": "0x25de227af58188e656524d1C32cCf7C078cdC891", + "betToken": { + "address": "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + "symbol": "WXDAI", + "decimals": 18 + }, + "api": "https://api.onchainfeed.org/api/v1/public", + "socket": "wss://streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-gnosis-v3" + }, + "137": { + "environment": "PolygonUSDT", + "core": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", + "relayer": "0x8dA05c0021e6b35865FDC959c54dCeF3A4AbBa9d", + "lp": "0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", + "cashout": "0x4a2BB4211cCF9b9eA6eF01D0a61448154ED19095", + "betToken": { + "address": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "symbol": "USDT", + "decimals": 6 + }, + "api": "https://api.onchainfeed.org/api/v1/public", + "socket": "wss://streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-polygon-v3" + }, + "8453": { + "environment": "BaseWETH", + "core": "0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d", + "relayer": "0xD2D508d66dB4fCE4B384e4C3EA2fa53BA43e73b5", + "lp": "0x1eD7368bc515E928A4007cEa61FB8a6F8863Af87", + "cashout": "0x6EDff24761F4473611B45BDAe4a779ff31af14Be", + "betToken": { + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": 18 + }, + "api": "https://api.onchainfeed.org/api/v1/public", + "socket": "wss://streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-base-v3" + }, + "80002": { + "environment": "PolygonAmoyUSDT", + "core": "0xCD0Db5ef28C3Bd3a69283372dE923Eb4DA0585F6", + "relayer": "0x48c9bE88706F22838070eE7C4bC74Ad7A8eeF114", + "lp": "0x0a75395Ff15d9557424b632cEBCac448D66F9779", + "cashout": "0x7dF132Ad2334a667A004049a75a4a8a530dc24F2", + "betToken": { + "address": "0xCf1b86ceD971b88C042C64A9c099377e2738073C", + "symbol": "USDT", + "decimals": 6 + }, + "api": "https://dev-api.onchainfeed.org/api/v1/public", + "socket": "wss://dev-streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-polygon-amoy-dev-v3" + }, + "84532": { + "environment": "BaseSepoliaWETH", + "core": "0x679CC42F69F98631668348d088086359D4217Ffa", + "relayer": "0x2De54cABa6A8198D9Dc51931485Bfdc8017aB0c5", + "lp": "0x4fE86EddCC22dc992b7994878169201Ec7ea50AB", + "cashout": "0xf52Eb6ec19d81Ea623A7d7Ad397287C0C40f2F37", + "betToken": { + "address": "0x9e09f213Ff75e53D52e9e777A6567A68683E935f", + "symbol": "WETH", + "decimals": 18 + }, + "api": "https://dev-api.onchainfeed.org/api/v1/public", + "socket": "wss://dev-streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-base-sepolia-dev-v3" + }, + "88882": { + "environment": "ChilizSpicyWCHZ", + "core": "0x524994dcA5EA2bc979ac5506E7195F28B4c16932", + "relayer": "0x725ec0A9eC9dC993A86d0eFD7fD78929d226AbE7", + "lp": "0x431A0993d29eEb0fF7e3FE351A303eF72195431a", + "cashout": "0x7c771a200EcD61A01af992360303f7b1465Cd8e3", + "betToken": { + "address": "0x721EF6871f1c4Efe730Dce047D40D1743B886946", + "symbol": "WCHZ", + "decimals": 18 + }, + "api": "https://dev-api.onchainfeed.org/api/v1/public", + "socket": "wss://dev-streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-chiliz-spicy-dev-v3" + }, + "88888": { + "environment": "ChilizWCHZ", + "core": "0xa5061617Ee6565CF48d7f0FEF06910b9fb9dE2b0", + "relayer": "0x81F72B93ABaf061535aaF5D831F05e0CC4084b32", + "lp": "0xEf6b12580301b04CD2551182C88623524B6e47b8", + "cashout": "0x3995eebB51793Ee353162E7400DB455B17dE3692", + "betToken": { + "address": "0x677F7e16C7Dd57be1D4C8aD1244883214953DC47", + "symbol": "WCHZ", + "decimals": 18 + }, + "api": "https://api.onchainfeed.org/api/v1/public", + "socket": "wss://streams.onchainfeed.org/v1/streams", + "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-chiliz-v3" + } + } +} diff --git a/tests/test_azuro.py b/tests/test_azuro.py new file mode 100644 index 0000000..310799e --- /dev/null +++ b/tests/test_azuro.py @@ -0,0 +1,370 @@ +"""Azuro normalizers and adapter, against payloads recorded from the live feed and bets subgraph.""" +from __future__ import annotations + +import pytest + +import synpath +from synpath import BadRequest, MarketNotFound, NotSupported +from synpath.azuro import ( + CHAINS, Azuro, bets_where, chain_of, contracts_of_limits, fee_schedule_of, game_of_condition, market_native, + next_bets_cursor, normalize_condition, normalize_event, normalize_trade, odds_price, outcome_order, + parse_market_id, status_of, +) + +from conftest import load + +GAMES = load("azuro_games.json") +CONDITIONS = load("azuro_conditions.json")["conditions"] +RESOLVED = load("azuro_conditions_resolved.json")["conditions"] +BY_IDS = load("azuro_games_by_ids.json")["games"] +BATCH = load("azuro_condition_batch.json")["conditions"] +SEARCH = load("azuro_search.json") +NAVIGATION = load("azuro_navigation.json") +BETS = load("azuro_bets.json")["data"]["v3Bets"] + +GAME = next(g for g in BY_IDS if g["gameId"] == "1006000000000094301994") +FINISHED = next(g for g in BY_IDS if g["gameId"] == "1006000000000002866602") +THREE_WAY = next(c for c in CONDITIONS if c["conditionId"] == "600610060000000000943019940000000000001406934671") +CORNERS = next(c for c in CONDITIONS if c["conditionId"] == "600610060000000000943019940000000000001802657415") +SETTLED_ID = "600610060000000000028666020000000000001624778125" + + +class FakeHttp: + """Answers by method and path from recorded payloads, and records the calls.""" + + def __init__(self, answers): + self.calls: list[tuple[str, str, object]] = [] + self.answers = answers + + def _answer(self, method, path, payload): + self.calls.append((method, path, payload)) + answer = self.answers.get(f"{method} {path}") + if answer is None: + raise MarketNotFound(f"azuro: no {path}") + return answer(payload) if callable(answer) else answer + + def get(self, path, params=None): + return self._answer("GET", path, params) + + def post(self, path, json=None): + return self._answer("POST", path, json) + + def close(self): + pass + + +def conditions_for(body): + wanted = set(body["gameIds"]) + rows = [c for c in CONDITIONS + RESOLVED if c["game"]["gameId"] in wanted] + return {"conditions": rows} + + +def batch_for(body): + return {"conditions": [c for c in BATCH if c["conditionId"] in body["conditionIds"]]} + + +def venue_with(answers, graph=None): + venue = Azuro(limiter=None) + venue.http = FakeHttp(answers) + venue.graph = FakeHttp(graph or {}) + return venue + + +FEED = { + "POST /market-manager/conditions-by-game-ids": conditions_for, + "POST /market-manager/games-by-ids": {"games": BY_IDS}, + "POST /market-manager/condition-batch": batch_for, +} + + +class TestPrices: + @pytest.mark.parametrize("odds, price", [("2", 0.5), ("1.36", 0.73529412), ("4.95", 0.2020202), (3, 0.33333333)]) + def test_a_price_is_one_over_the_odds(self, odds, price): + assert odds_price(odds) == price + + @pytest.mark.parametrize("odds", ["1", "0.9", None, "", "x"]) + def test_odds_of_one_or_less_are_no_price(self, odds): + assert odds_price(odds) is None + + +class TestIds: + def test_two_outcomes_are_one_market_larger_conditions_one_per_outcome(self): + assert market_native("123") == "123" and market_native("123", 2) == "123-2" + assert parse_market_id("123") == ("123", None) and parse_market_id("123-2") == ("123", "2") + + @pytest.mark.parametrize("bad", ["0xabc", "12-", "12-x", "", "12 3"]) + def test_anything_else_is_refused(self, bad): + with pytest.raises(BadRequest): + parse_market_id(bad) + + def test_a_condition_names_its_game(self): + assert game_of_condition(THREE_WAY["conditionId"]) == THREE_WAY["game"]["gameId"] + assert all(game_of_condition(c["conditionId"]) == c["game"]["gameId"] for c in CONDITIONS + RESOLVED) + assert game_of_condition("123") is None + + def test_the_venue_is_registered(self): + assert "azuro" in synpath.ids.VENUES and synpath.exchanges["azuro"] is Azuro + assert synpath.ids.split("azuro:123-4") == ("azuro", "123-4") + + +class TestMarkets: + def test_a_two_outcome_condition_is_one_market_lower_outcome_yes(self): + [market] = normalize_condition(CORNERS, GAME) + assert market.id == f"azuro:{CORNERS['conditionId']}" and market.event_id == f"azuro:{GAME['gameId']}" + assert (market.yes.label, market.no.label) == ("Over 10.5", "Under 10.5") + assert market.title == "Trefelin FC - Flint Town United — Total Corners Over/Under: Over 10.5" + assert market.status == "open" and market.active and market.book_model == "shared_complement" + + def test_its_quote_is_the_pools_odds(self): + [market] = normalize_condition(CORNERS, GAME) + over, under = 1 / 2.04, 1 / 1.65 + assert market.yes.quote.ask == round(over, 8) and market.yes.quote.bid == round(1 - under, 8) + assert market.no.quote.ask == round(under, 8) and market.no.quote.bid == round(1 - over, 8) + assert market.yes.quote.bid < market.yes.quote.ask, "the pool's margin keeps the bid under the ask" + assert market.info["odds"] == {"yes": "2.04", "no": "1.65"} + + def test_yes_is_fixed_by_outcome_id_not_the_feeds_order(self): + reordered = {**CORNERS, "outcomes": list(reversed(CORNERS["outcomes"]))} + assert normalize_condition(reordered, GAME)[0].yes.label == "Over 10.5" + assert [o["outcomeId"] for o in outcome_order(THREE_WAY)] == ["1", "2", "3"] + + def test_a_larger_condition_is_one_market_per_outcome_with_only_yes_sold(self): + markets = normalize_condition(THREE_WAY, GAME) + assert [m.id for m in markets] == [f"azuro:{THREE_WAY['conditionId']}-{i}" for i in (1, 2, 3)] + draw = markets[0] + assert draw.yes.label == "Draw (+1)" and draw.outcome_label == "Draw (+1)" + assert draw.yes.quote.ask == round(1 / 4.95, 8) and draw.yes.quote.bid is None and draw.no.quote.ask is None + assert draw.event_id == f"azuro:{GAME['gameId']}" and "One of 3 outcomes" in draw.description + + def test_every_condition_in_a_game_normalizes(self): + markets = [m for c in CONDITIONS for m in normalize_condition(c, GAME)] + assert len(markets) == sum(1 if len(c["outcomes"]) == 2 else len(c["outcomes"]) for c in CONDITIONS) + for market in markets: + for quote in (market.yes.quote, market.no.quote): + for price in (quote.bid, quote.ask, quote.mid): + assert price is None or 0 < price < 1 + + def test_a_stopped_condition_is_closed_without_a_quote(self): + stopped = next(c for c in CONDITIONS if c["state"] == "Stopped") + [market] = normalize_condition(stopped, GAME) + assert market.status == "closed" and not market.active and market.yes.quote.ask is None + + def test_a_resolved_condition_is_settled_with_its_winner(self): + condition = next(c for c in BATCH if c["conditionId"] == SETTLED_ID) + [market] = normalize_condition(condition, FINISHED) + assert market.status == "settled" and market.native_status == "resolved" + assert market.yes.label == "Over 11.5" and market.info["winning_outcome"] == "yes" + assert market.yes.quote.ask is None + + def test_a_cancelled_condition_is_void(self): + cancelled = {**CORNERS, "state": "Canceled"} + assert status_of(cancelled)[0] == "settled" + assert normalize_condition(cancelled, GAME)[0].info["winning_outcome"] == "void" + + def test_sport_league_and_country_are_the_tags(self): + [market] = normalize_condition(CORNERS, GAME) + assert market.tags == ["football", "cymru premier", "wales"] and market.category == "football" + assert market.series_id == GAME["league"]["id"] + + def test_a_game_is_the_event(self): + event = normalize_event(GAME, [c for c in CONDITIONS if c["game"]["gameId"] == GAME["gameId"]]) + assert event.id == f"azuro:{GAME['gameId']}" and event.title == GAME["title"] + assert event.status == "open" and event.info["starts_at"] == int(GAME["startsAt"]) * 1000 + assert {m.event_id for m in event.markets} == {event.id} + + def test_a_finished_game_with_every_condition_settled_or_stopped(self): + event = normalize_event(FINISHED, RESOLVED) + assert event.status in ("closed", "settled") and event.native_status == "finished" + + +class TestTrades: + def test_a_bet_on_the_no_outcome_is_a_sell_at_one_minus_its_price(self): + bet = BETS[0] + trade = normalize_trade(bet, market_id="azuro:x", yes_outcome="1", binary=True) + assert trade.side == "sell" and trade.price == round(1 - 1 / 2.6, 8) + assert trade.quantity == 28.6 and trade.timestamp == int(bet["createdBlockTimestamp"]) * 1000 + + def test_a_bet_on_yes_is_a_buy(self): + trade = normalize_trade(BETS[0], market_id="azuro:x", yes_outcome="2", binary=True) + assert trade.side == "buy" and trade.price == round(1 / 2.6, 8) + + def test_on_one_outcome_of_a_larger_condition_other_outcomes_are_not_its_trades(self): + assert normalize_trade(BETS[0], market_id="azuro:x-1", yes_outcome="1", binary=False) is None + + def test_a_combo_is_not_a_trade(self): + combo = {**BETS[0], "selections": BETS[0]["selections"] * 2} + assert normalize_trade(combo, market_id="azuro:x", yes_outcome="1", binary=True) is None + + def test_the_cursor_keeps_bets_that_share_the_boundary_second(self): + rows = [{"id": "a", "createdBlockTimestamp": "20"}, {"id": "b", "createdBlockTimestamp": "10"}, + {"id": "c", "createdBlockTimestamp": "10"}] + cursor = next_bets_cursor(rows, None, full=True) + assert cursor == "10:b,c" + where = bets_where("123", cursor) + assert where["createdBlockTimestamp_lte"] == "10" and where["id_not_in"] == ["b", "c"] + assert next_bets_cursor([{"id": "d", "createdBlockTimestamp": "10"}], cursor, full=True) == "10:b,c,d" + assert next_bets_cursor(rows, None, full=False) is None + with pytest.raises(BadRequest): + bets_where("123", "x:y") + + +class TestFees: + def test_nothing_on_top_of_the_odds(self): + schedule = fee_schedule_of("123") + assert schedule.fee_type == "pool_margin" and schedule.estimate(0.4, 100) == 0.0 + assert schedule.info["currency"] == "USDT" + assert Azuro(chain="base").fetch_fee_schedule("azuro:123").info["currency"] == "WETH" + + +class TestChains: + def test_chains_by_name_or_id(self): + assert chain_of("polygon").chain_id == 137 and chain_of(8453).name == "base" + assert chain_of("polygon-amoy").testnet and chain_of("gnosis").discontinued + with pytest.raises(BadRequest): + chain_of("solana") + + def test_each_chain_has_its_own_environment_and_endpoints(self): + assert Azuro().environment == "PolygonUSDT" and Azuro(chain="base").environment == "BaseWETH" + assert CHAINS["polygon_amoy"].api_url.startswith("https://dev-api.") + assert CHAINS["base"].subgraph_url.endswith("azuro-api-base-v3") + + +class TestAdapter: + def test_fetch_markets_walks_live_games_then_prematch(self): + def games(params): + if params["gameState"] == "Live": + return {"games": [], "page": 1, "totalPages": 0} + return {**GAMES, "games": [GAME]} if params["page"] == 1 else {"games": [], "totalPages": 1} + + venue = venue_with({**FEED, "GET /market-manager/games-by-filters": games}) + page = venue.fetch_markets(limit=5) + assert len(page) == 5 and all(m.status == "open" for m in page) + assert page.next_cursor == "Prematch:1:0:5" + following = venue.fetch_markets(limit=500, cursor=page.next_cursor) + assert following[0].id not in {m.id for m in page} + params = [c[2] for c in venue.http.calls if c[1] == "/market-manager/games-by-filters"] + assert params[0]["environment"] == "PolygonUSDT" and params[0]["perPage"] == 10 + + def test_only_open_markets_are_listed(self): + venue = venue_with({}) + for status in ("closed", "settled", "all"): + with pytest.raises(NotSupported): + venue.fetch_markets(status=status) + assert not venue.http.calls + + def test_a_bad_cursor_is_refused(self): + venue = venue_with({}) + for cursor in ("x", "Live:1", "Finished:1:0:0", "Live:a:0:0"): + with pytest.raises(BadRequest): + venue.fetch_markets(cursor=cursor) + + def test_search(self): + venue = venue_with({**FEED, "GET /market-manager/search": SEARCH}) + markets = venue.fetch_markets(query="Trefelin", limit=3) + assert len(markets) == 3 and markets[0].event_id == f"azuro:{GAME['gameId']}" + events = venue.fetch_events(query="Trefelin") + assert [e.id for e in events] == [f"azuro:{GAME['gameId']}"] and events.next_cursor is None + with pytest.raises(BadRequest): + venue.fetch_markets(query="ab") + + def test_fetch_events_pages_by_game_state(self): + venue = venue_with({**FEED, "GET /market-manager/games-by-filters": {**GAMES, "games": [GAME]}}) + page = venue.fetch_events(limit=1) + assert page[0].id == f"azuro:{GAME['gameId']}" and page.next_cursor == "Live:2" + + def test_fetch_market_reads_the_game_the_condition_names(self): + venue = venue_with(FEED) + market = venue.fetch_market(f"azuro:{CORNERS['conditionId']}") + assert market.title.startswith(GAME["title"]) and market.yes.label == "Over 10.5" + assert [c[1] for c in venue.http.calls] == ["/market-manager/conditions-by-game-ids", "/market-manager/games-by-ids"] + + def test_a_settled_market_is_found_too(self): + market = venue_with(FEED).fetch_market(f"azuro:{SETTLED_ID}") + assert market.status == "settled" and market.info["winning_outcome"] == "yes" + + def test_a_larger_condition_needs_an_outcome(self): + venue = venue_with(FEED) + with pytest.raises(BadRequest, match="3 outcomes, one market each"): + venue.fetch_market(f"azuro:{THREE_WAY['conditionId']}") + with pytest.raises(BadRequest, match="two outcomes"): + venue.fetch_market(f"azuro:{CORNERS['conditionId']}-1") + assert venue.fetch_market(f"azuro:{THREE_WAY['conditionId']}-3").yes.label == "Trefelin FC (-1)" + + def test_an_unknown_market(self): + venue = venue_with({**FEED, "POST /market-manager/conditions-by-game-ids": {"conditions": []}, + "POST /market-manager/condition-batch": {"conditions": []}}) + with pytest.raises(MarketNotFound): + venue.fetch_market("azuro:600610060000000000943019940000000000000000000001") + + def test_many_markets_in_the_order_asked(self): + ids = [f"azuro:{THREE_WAY['conditionId']}-2", f"azuro:{SETTLED_ID}", "azuro:999", f"azuro:{CORNERS['conditionId']}"] + markets = venue_with(FEED).fetch_markets_by_ids(ids) + assert [m.id for m in markets] == [ids[0], ids[1], ids[3]] + + def test_the_book_is_the_quote_sized_by_the_pools_limits(self): + def limits(body): + outcome = body["bets"][0]["outcomeId"] + return {"response": {"minBet": 1, "maxBet": 100 if outcome == 1 else 10, "maxPayout": "150"}} + + venue = venue_with({**FEED, "POST /bet/calculation": limits}) + book = venue.fetch_order_book(f"azuro:{CORNERS['conditionId']}") + assert book.asks[0].price == round(1 / 2.04, 8) and book.asks[0].size == 150.0 + assert book.bids[0].price == round(1 - 1 / 1.65, 8) and book.bids[0].size == 16.5 + assert book.depth_scope == "top_n" and book.info["pool"] is True + sent = [c[2] for c in venue.http.calls if c[1] == "/bet/calculation"] + assert [s["bets"][0]["outcomeId"] for s in sent] == [1, 2] and sent[0]["environment"] == "PolygonUSDT" + no = venue.fetch_order_book(f"azuro:{CORNERS['conditionId']}", side="no") + assert no.derived and no.asks[0].price == round(1 / 1.65, 8) + + def test_one_outcome_has_only_an_ask(self): + venue = venue_with({**FEED, "POST /bet/calculation": {"response": {"maxBet": 5, "maxPayout": "20"}}}) + book = venue.fetch_order_book(f"azuro:{THREE_WAY['conditionId']}-1") + assert book.bids == [] and book.asks[0].size == round(min(20.0, 5 * 4.95), 6) + + def test_limits_size_in_contracts(self): + assert contracts_of_limits({"max_payout": 150.0, "max_bet": 100.0}, "2.04") == 150.0 + assert contracts_of_limits({"max_payout": None, "max_bet": None}, "2.04") is None + + def test_limits_are_read_per_selection(self): + venue = venue_with({**FEED, "POST /bet/calculation": {"response": {"minBet": None, "maxBet": 50, + "maxPayout": "120.5"}}}) + limits = venue.fetch_limits(f"azuro:{CORNERS['conditionId']}", side="sell", account="0x" + "1" * 40) + assert limits["outcome_id"] == "2" and limits["max_payout"] == 120.5 and limits["min_bet"] is None + assert venue.http.calls[-1][2]["wallet"] == "0x" + "1" * 40 + with pytest.raises(BadRequest): + venue.fetch_limits(f"azuro:{THREE_WAY['conditionId']}-1", side="sell") + + def test_trades_come_from_the_subgraph(self): + graph = {"POST ": {"data": {"v3Bets": BETS}}} + venue = venue_with(FEED, graph) + trades = venue.fetch_trades(f"azuro:{SETTLED_ID}") + [(_, _, query)] = venue.graph.calls + assert query["variables"]["where"] == {"_conditionIds_contains": [SETTLED_ID], "type": "Ordinar"} + assert all(t.market_id == f"azuro:{SETTLED_ID}" for t in trades) + assert [t.timestamp for t in trades] == sorted(t.timestamp for t in trades) + # The recorded bets are on the game's other conditions too; the tape + # here is filtered by the subgraph, so every row is read as this market's. + assert trades.next_cursor is None + + def test_candles_from_the_tape(self): + graph = {"POST ": {"data": {"v3Bets": BETS}}} + candles = venue_with(FEED, graph).fetch_ohlcv(f"azuro:{SETTLED_ID}", timeframe="1h") + assert candles and all(c.price_source == "trade" for c in candles) + assert sum(c.trade_count or 0 for c in candles) == len(BETS) + + def test_subgraph_errors_raise(self): + from synpath import ExchangeError + + venue = venue_with(FEED, {"POST ": {"errors": [{"message": "bad"}]}}) + with pytest.raises(ExchangeError): + venue.fetch_trades(f"azuro:{SETTLED_ID}") + + def test_sports(self): + sports = venue_with({"GET /market-manager/navigation": NAVIGATION}).fetch_sports() + assert sports and {"slug", "countries", "activeGamesCount"} <= set(sports[0]) + + def test_refresh_quotes(self): + venue = venue_with(FEED) + [market] = normalize_condition({**CORNERS, "outcomes": [{**o, "odds": "1.9"} for o in CORNERS["outcomes"]]}, GAME) + fresh = venue.refresh_quotes(market) + assert fresh.yes.quote.ask == round(1 / 2.04, 8) and fresh.title == market.title diff --git a/tests/test_azuro_live.py b/tests/test_azuro_live.py new file mode 100644 index 0000000..8f74bbb --- /dev/null +++ b/tests/test_azuro_live.py @@ -0,0 +1,54 @@ +"""Azuro against the real feed and bets subgraph: run with `pytest -m live`. No key needed. +The bet routes (`/bet/calculation`, so books and limits) and the socket answer +some regions with a 503; those checks skip there.""" +from __future__ import annotations + +import pytest + +from synpath import Azuro, ExchangeNotAvailable + +pytestmark = pytest.mark.live + + +@pytest.fixture(scope="module") +def venue(): + with Azuro() as azuro: + yield azuro + + +@pytest.fixture(scope="module") +def markets(venue): + return venue.fetch_markets(limit=40) + + +def test_the_catalog_is_open_named_and_quoted(markets): + assert markets and all(m.status == "open" and m.yes.label and m.event_id for m in markets) + assert any(m.yes.quote.ask is not None for m in markets) + + +def test_paging_neither_repeats_nor_drops(venue, markets): + following = venue.fetch_markets(limit=40, cursor=markets.next_cursor) + assert not {m.id for m in markets} & {m.id for m in following} + + +def test_a_market_reads_back_by_id(venue, markets): + market = venue.fetch_market(markets[0].id) + assert market.id == markets[0].id and market.title == markets[0].title + + +def test_events_hold_their_markets(venue): + events = venue.fetch_events(limit=2) + assert events and all(e.markets and {m.event_id for m in e.markets} == {e.id} for e in events) + + +def test_sports(venue): + assert venue.fetch_sports() + + +def test_the_book_where_the_bet_routes_answer(venue, markets): + market = next(m for m in markets if m.yes.quote.ask is not None) + try: + book = venue.fetch_order_book(market.id) + except ExchangeNotAvailable: + pytest.skip("the feed's bet routes refuse this region") + assert book.asks and 0 < book.asks[0].price < 1 and book.asks[0].size > 0 diff --git a/tests/test_azuro_trading.py b/tests/test_azuro_trading.py new file mode 100644 index 0000000..e29847e --- /dev/null +++ b/tests/test_azuro_trading.py @@ -0,0 +1,499 @@ +"""Azuro bets: signing checked against `@azuro-org/toolkit` and viem +(`tests/samples/azuro_signing_vectors.json`, made with the well-known Hardhat +test key), bet bodies, bet records, and the adapter against a scripted venue. +The venue's bet routes need a wallet and refuse this test machine's region, so +the bet order records follow the toolkit's `BetOrderData` type.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +import httpx +import pytest +from eth_account import Account as EthAccount +from eth_account.messages import _hash_eip191_message, encode_typed_data + +import synpath +from synpath.azuro import CHAINS +from synpath.trading import azuro_signing as sig +from synpath.trading.azuro import ( + AzuroTrading, build_bet, check_request, fill_of_row, order_of_row, positions_of, relayer_fee_of, settlement_of_row, +) +from synpath.trading.credentials import AzuroCredentials, CredentialsMissing, load_azuro, load_credentials +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected +from synpath.trading.polymarket_signing import WalletSigner +from synpath.trading.types import OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce + +from conftest import load + +VECTORS = load("azuro_signing_vectors.json") +KEY = VECTORS["private_key"] +OWNER = EthAccount.from_key(KEY) +POLYGON = CHAINS["polygon"] +CORNERS = "600610060000000000943019940000000000001802657415" +THREE_WAY = "600610060000000000943019940000000000001406934671" +OUTCOMES = {CORNERS: ["1", "2"], THREE_WAY: ["1", "2", "3"]} + + +def request(**changes) -> OrderRequest: + base = {"market_id": f"azuro:{CORNERS}", "side": Side.BUY, "quantity": Decimal("10"), "price": Decimal("0.5"), + "type": OrderType.MARKET} + return OrderRequest(**{**base, **changes}) + + +def built(req: OrderRequest, outcomes=None): + return build_bet(req, chain=POLYGON, signer=WalletSigner(KEY), outcomes=outcomes or OUTCOMES[CORNERS], + relayer_fee=12345, nonce=7, now=1_800_000_000) + + +def row(**changes) -> dict: + """A bet order as the toolkit's `BetOrderData` describes it.""" + base = { + "id": f"{OWNER.address.lower()}_1792780000000", "state": "Accepted", "environment": "PolygonUSDT", + "betType": "ORDINARY", "core": POLYGON.core, "bettor": OWNER.address, "owner": OWNER.address, + "affiliate": OWNER.address, "amount": 5, "payout": None, "odds": 2.04, "bonusId": None, "isFreebet": False, + "betId": 472100, "txHash": "0x" + "ab" * 32, "margin": "0.05", "settledMargin": None, "result": None, + "createdAt": "2026-10-23T10:00:00.000Z", "updatedAt": "2026-10-23T10:00:05.000Z", "settledAt": None, + "redeemedAt": None, "meta": None, "error": None, "errorMessage": None, + "conditions": [{"conditionId": CORNERS, "outcomeId": 1, "orderId": "x", "gameId": "1006000000000094301994", + "gameState": "Prematch", "conditionMargin": "0.05", "selectionMargin": "0.05", + "settledSelectionMargin": None, "result": None, "price": "2.04", "potentialAmount": "10.2", + "potentialLoss": "5.2"}], + } + return {**base, **changes} + + +class TestSigning: + @pytest.mark.parametrize("case", VECTORS["cases"], ids=lambda c: c["name"]) + def test_the_digest_and_signature_match_the_toolkit(self, case): + data, bet = case["clientData"], case["bet"] + assert "0x" + sig.bet_digest(data, bet).hex() == case["digest"] + assert sig.sign_bet(WalletSigner(KEY), data, bet) == case["signature"] + assert sig.bet_domain(data) == case["domain"] + + @pytest.mark.parametrize("case", VECTORS["cases"], ids=lambda c: c["name"]) + def test_the_typed_data_is_what_a_wallet_signs(self, case): + typed = sig.bet_typed_data(case["clientData"], case["bet"]) + encoded = encode_typed_data(full_message=typed) + assert "0x" + _hash_eip191_message(encoded).hex() == case["digest"] + assert "0x" + OWNER.sign_message(encoded).signature.hex().removeprefix("0x") == case["signature"] + + def test_the_cashout_matches_the_toolkit(self): + case = VECTORS["cashout"] + message = sig.cashout_message(POLYGON, bet_id=case["tokenId"], cashout_odds=case["cashoutOdds"], + expires_at=case["expiredAt"], attention=case["attention"]) + assert "0x" + sig.cashout_digest(POLYGON, message).hex() == case["digest"] + assert sig.sign_cashout(WalletSigner(KEY), POLYGON, message) == case["signature"] + assert sig.cashout_domain(POLYGON) == case["domain"] + assert message["items"][0]["minOdds"] == int(case["message"]["items"][0]["minOdds"]) + + def test_the_chains_match_the_toolkit(self): + listed = {int(k): v for k, v in VECTORS["chains"].items()} + for chain in CHAINS.values(): + entry = listed[chain.chain_id] + assert (chain.environment, chain.core, chain.relayer, chain.lp, chain.cashout) == \ + (entry["environment"], entry["core"], entry["relayer"], entry["lp"], entry["cashout"]) + assert (chain.token.lower(), chain.decimals, chain.symbol) == \ + (entry["betToken"]["address"].lower(), entry["betToken"]["decimals"], entry["betToken"]["symbol"]) + assert (chain.api_url, chain.socket_url, chain.subgraph_url) == (entry["api"], entry["socket"], entry["bets"]) + + +class TestAmounts: + def test_minimum_odds_are_one_over_the_price_rounded_up(self): + assert sig.min_odds(Decimal("0.5")) == 2 * 10 ** 12 + assert sig.min_odds(Decimal("0.74")) == 1_351_351_351_352 + assert Decimal(10 ** 12) / sig.min_odds(Decimal("0.74")) <= Decimal("0.74") + for bad in ("0", "1", "1.2"): + with pytest.raises(InvalidOrder): + sig.min_odds(Decimal(bad)) + + def test_the_stake_is_contracts_times_price_rounded_down(self): + assert sig.stake(Decimal("10"), Decimal("0.5"), 6) == 5_000_000 + assert sig.stake(Decimal("3"), Decimal("0.333333"), 6) == 999_999 + assert sig.stake(Decimal("0.01"), Decimal("0.5"), 18) == 5 * 10 ** 15 + with pytest.raises(InvalidOrder): + sig.stake(Decimal("0.000001"), Decimal("0.1"), 6) + + def test_odds_round_trip(self): + assert sig.odds_raw("1.85") == 1_850_000_000_000 and sig.odds_of_raw(1_850_000_000_000) == Decimal("1.85") + + +class TestBets: + def test_a_buy_backs_the_yes_outcome(self): + body, bet = built(request()) + assert bet == {"conditionId": CORNERS, "outcomeId": "1", "minOdds": str(2 * 10 ** 12), "amount": "5000000", + "nonce": "7"} + assert body["environment"] == "PolygonUSDT" and body["bettor"] == body["betOwner"] == OWNER.address.lower() + data = body["clientBetData"]["clientData"] + assert data["core"] == POLYGON.core and data["chainId"] == 137 and data["relayerFeeAmount"] == "12345" + assert data["expiresAt"] == 1_800_000_000 + 300 and data["affiliate"] == OWNER.address + assert data["attention"] == sig.BET_ATTENTION and not data["isBetSponsored"] + assert body["clientBetData"]["bet"]["outcomeId"] == 1, "the API takes the outcome as a number" + + def test_the_signature_recovers_the_bettor(self): + body, bet = built(request()) + data = body["clientBetData"]["clientData"] + encoded = encode_typed_data(full_message=sig.bet_typed_data(data, bet)) + assert EthAccount.recover_message(encoded, signature=body["bettorSignature"]) == OWNER.address + + def test_a_sell_backs_the_other_outcome_at_one_minus_the_price(self): + _, bet = built(request(side=Side.SELL, price=Decimal("0.6"))) + assert bet["outcomeId"] == "2" and bet["minOdds"] == str(2_500_000_000_000) and bet["amount"] == "4000000" + + def test_one_outcome_of_a_larger_condition(self): + _, bet = built(request(market_id=f"azuro:{THREE_WAY}-3", price=Decimal("0.2")), OUTCOMES[THREE_WAY]) + assert bet["conditionId"] == THREE_WAY and bet["outcomeId"] == "3" and bet["minOdds"] == str(5 * 10 ** 12) + with pytest.raises(InvalidOrder, match="only YES"): + built(request(market_id=f"azuro:{THREE_WAY}-3", side=Side.SELL), OUTCOMES[THREE_WAY]) + with pytest.raises(InvalidOrder, match="name one"): + built(request(market_id=f"azuro:{THREE_WAY}"), OUTCOMES[THREE_WAY]) + + def test_an_affiliate_can_be_named(self): + body, _ = build_bet(request(), chain=POLYGON, signer=WalletSigner(KEY), outcomes=["1", "2"], relayer_fee=1, + affiliate="0x" + "11" * 20, nonce=1, now=0) + assert body["clientBetData"]["clientData"]["affiliate"] == "0x" + "11" * 20 + + @pytest.mark.parametrize("changes", [ + {"type": OrderType.LIMIT}, # gtc: would rest + {"type": OrderType.LIMIT, "time_in_force": TimeInForce.GTD, "expires_at": 1}, + {"post_only": True}, + {"reduce_only": True}, + {"type": OrderType.STOP_MARKET, "stop_price": Decimal("0.4")}, + {"price": None}, + ]) + def test_refused_before_signing(self, changes): + with pytest.raises(InvalidOrder): + check_request(request(**changes)) + + def test_an_immediate_limit_is_a_bet_too(self): + check_request(request(type=OrderType.LIMIT, time_in_force=TimeInForce.IOC)) + check_request(request(type=OrderType.LIMIT, time_in_force=TimeInForce.FOK)) + + @pytest.mark.parametrize("info", [{"relayerFeeAmount": "123"}, [{"relayerFeeAmount": "123"}]]) + def test_the_relayer_fee_from_either_shape(self, info): + assert relayer_fee_of(info) == 123 + + +class TestRecords: + def test_an_accepted_bet_is_a_filled_order_on_yes(self): + order = order_of_row(row(), outcomes=["1", "2"]) + assert order.market_id == f"azuro:{CORNERS}" and order.side == Side.BUY + assert order.status == OrderStatus.CLOSED and order.quantity == order.filled == Decimal("10.2") + assert order.price == Decimal(1) / Decimal("2.04") and order.cost == Decimal("5") + assert order.type == OrderType.MARKET and order.time_in_force == TimeInForce.FOK + + @pytest.mark.parametrize("state, status", [ + ("Created", OrderStatus.PENDING), ("Placed", OrderStatus.PENDING), ("Sent", OrderStatus.PENDING), + ("Rejected", OrderStatus.REJECTED), ("Canceled", OrderStatus.CANCELED), ("Settled", OrderStatus.CLOSED), + ("PendingCancel", OrderStatus.PENDING_CANCEL), ("CancelFailed", OrderStatus.CLOSED), + ]) + def test_states(self, state, status): + order = order_of_row(row(state=state), outcomes=["1", "2"]) + assert order.status == status + assert order.filled == (order.quantity if state in ("Settled", "CancelFailed") else 0) + + def test_a_bet_on_the_other_outcome_is_a_sell(self): + legs = [{**row()["conditions"][0], "outcomeId": 2}] + order = order_of_row(row(conditions=legs, odds=1.65), outcomes=["1", "2"]) + assert order.side == Side.SELL and order.price == 1 - Decimal(1) / Decimal("1.65") + + def test_a_larger_conditions_outcome_is_its_own_market(self): + legs = [{**row()["conditions"][0], "conditionId": THREE_WAY, "outcomeId": 3}] + order = order_of_row(row(conditions=legs), outcomes=["1", "2", "3"]) + assert order.market_id == f"azuro:{THREE_WAY}-3" and order.side == Side.BUY + + def test_a_fill_and_a_settlement(self): + fill = fill_of_row(row(), outcomes=["1", "2"]) + assert fill.quantity == Decimal("10.2") and fill.id == "0x" + "ab" * 32 + assert fill_of_row(row(state="Created"), outcomes=["1", "2"]) is None + won = settlement_of_row(row(state="Settled", result="Won", payout=10.2), outcomes=["1", "2"]) + assert won.won and won.payout == Decimal("10.2") and won.pnl == Decimal("5.2") + lost = settlement_of_row(row(state="Settled", result="Lost"), outcomes=["1", "2"]) + assert lost.won is False and lost.pnl == Decimal("-5") + void = settlement_of_row(row(state="Settled", result="Canceled"), outcomes=["1", "2"]) + assert void.won is None and void.pnl == 0 + assert settlement_of_row(row(), outcomes=["1", "2"]) is None + + def test_positions_net_on_yes(self): + other = {**row()["conditions"][0], "outcomeId": 2, "potentialAmount": "4"} + rows = [row(), row(id="b", conditions=[other], amount=2.5), row(id="c", result="Won")] + [position] = positions_of(rows, outcomes={CORNERS: ["1", "2"]}) + assert position.side == PositionSide.LONG and position.contracts == Decimal("6.2") + assert (position.inventory_yes, position.inventory_no, position.margin) == (Decimal("10.2"), Decimal("4"), Decimal("7.5")) + + def test_a_combo_is_marked(self): + order = order_of_row(row(betType="COMBO"), outcomes=["1", "2"]) + assert order.info["combo"] and order.quantity == Decimal("5") * Decimal("2.04") + assert fill_of_row(row(betType="COMBO"), outcomes=["1", "2"]) is None + + +class Venue: + def __init__(self, **answers): + self.answers = answers + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + key = f"{req.method} {req.url.path}" + if req.url.host == "rpc.test": + body = json.loads(req.content) + key = f"RPC {body['method']}" + answer = self.answers.get(key) + if answer is None: + return httpx.Response(404, json={"message": "not found"}) + if callable(answer): + answer = answer(req) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + def sent(self, key: str) -> list[httpx.Request]: + return [r for r in self.requests if f"{r.method} {r.url.path}" == key] + + +class Catalog: + def __init__(self): + self.asked: list[list[str]] = [] + + def condition_outcomes(self, condition_ids): + self.asked.append(list(condition_ids)) + return {c: OUTCOMES[c] for c in condition_ids if c in OUTCOMES} + + def close(self): + pass + + +GAS = "GET /api/v1/public/bet/gas-info" +PLACE = "POST /api/v1/public/bet/orders/ordinar" +BETTOR = f"GET /api/v1/public/bet/orders/bettor/{OWNER.address}" + + +def adapter(venue: Venue, **creds) -> AzuroTrading: + credentials = AzuroCredentials(private_key=KEY, rpc_url="https://rpc.test/", **creds) + return AzuroTrading(credentials, catalog=Catalog(), client=httpx.AsyncClient(transport=httpx.MockTransport(venue))) + + +def run(coro): + return asyncio.run(coro) + + +class TestAdapter: + def test_a_bet_is_signed_and_handed_to_the_relayer(self): + venue = Venue(**{GAS: {"relayerFeeAmount": "20000", "symbol": "USDT", "decimals": 6}, + PLACE: {"id": "0xf39f_1", "state": "Created"}}) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.create_order(request()) + + order = run(go()) + [fee] = venue.sent(GAS) + assert fee.url.params["environment"] == "PolygonUSDT" + [sent] = venue.sent(PLACE) + body = json.loads(sent.content) + assert body["clientBetData"]["clientData"]["relayerFeeAmount"] == "20000" + assert body["clientBetData"]["bet"]["amount"] == "5000000" and body["bettorSignature"].startswith("0x") + assert (order.id, order.status, order.filled, order.quantity) == ("0xf39f_1", OrderStatus.PENDING, 0, Decimal("10")) + assert order.price == Decimal("0.5") and order.market_id == f"azuro:{CORNERS}" + + def test_nonces_never_repeat(self): + venue = Venue(**{GAS: {"relayerFeeAmount": "1"}, PLACE: {"id": "x", "state": "Created"}}) + + async def go(): + async with adapter(venue) as azuro: + await azuro.create_orders([request(), request(), request()]) + + run(go()) + nonces = [json.loads(r.content)["clientBetData"]["bet"]["nonce"] for r in venue.sent(PLACE)] + assert len(set(nonces)) == 3 + + def test_a_refusal_is_typed(self): + venue = Venue(**{GAS: {"relayerFeeAmount": "1"}, + PLACE: httpx.Response(400, json={"errorMessage": "Insufficient allowance", "error": "X"})}) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.create_orders([request(), request(side=Side.SELL, market_id=f"azuro:{THREE_WAY}-1")]) + + allowance, refused = run(go()) + assert isinstance(allowance, InsufficientFunds) and isinstance(refused, InvalidOrder) + assert len(venue.sent(PLACE)) == 1, "the refused request was never sent" + + def test_a_bet_not_created_is_rejected(self): + venue = Venue(**{GAS: {"relayerFeeAmount": "1"}, + PLACE: {"id": "x", "state": "Rejected", "errorMessage": "condition stopped"}}) + + async def go(): + async with adapter(venue) as azuro: + await azuro.create_order(request()) + + with pytest.raises(OrderRejected, match="condition stopped"): + run(go()) + + def test_an_order_is_read_back_and_awaited(self): + states = iter(["Created", "Sent", "Accepted"]) + venue = Venue(**{"GET /api/v1/public/bet/orders/abc": lambda _r: row(id="abc", state=next(states))}) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.wait_for_order("abc", poll_s=0) + + order = run(go()) + assert order.status == OrderStatus.CLOSED and len(venue.requests) == 3 + + def test_an_unknown_order(self): + async def go(): + async with adapter(Venue()) as azuro: + await azuro.fetch_order("nope") + + with pytest.raises(OrderNotFound): + run(go()) + + def test_nothing_can_be_cancelled(self): + async def go(): + async with adapter(Venue()) as azuro: + await azuro.cancel_order("abc") + + with pytest.raises(synpath.NotSupported): + run(go()) + assert AzuroTrading.has["cancel_order"] is False + + def test_the_bettors_lists(self): + pending = row(id="p", state="Sent") + settled = row(id="s", state="Settled", result="Won", payout=10.2, settledAt="2026-10-23T12:00:00.000Z") + venue = Venue(**{BETTOR: {"orders": [row(), pending, settled]}}) + + async def go(): + async with adapter(venue) as azuro: + return (await azuro.fetch_orders(), await azuro.fetch_open_orders(), await azuro.fetch_my_trades(), + await azuro.fetch_settlements(), await azuro.fetch_positions()) + + orders, open_orders, fills, settlements, positions = run(go()) + assert len(orders) == 3 and [o.id for o in open_orders] == [row()["id"], "p", "s"] + params = venue.sent(BETTOR)[1].url.params + assert params.get_list("states") == ["Created", "Placed", "Sent", "PendingCancel"] + assert params["environment"] == "PolygonUSDT" and params["offset"] == "0" + assert len(fills) == 2 and settlements[0].won and positions[0].contracts == Decimal("10.2") + + def test_balance_and_allowance_are_read_on_chain(self): + def call(req): + body = json.loads(req.content) + target = body["params"][0] + assert target["to"] == POLYGON.token + if target["data"].startswith("0x70a08231"): + return {"jsonrpc": "2.0", "id": 1, "result": hex(12_500_000)} + assert target["data"][34:74].lower() == OWNER.address[2:].lower() + assert target["data"][-40:].lower() == POLYGON.relayer[2:].lower() + return {"jsonrpc": "2.0", "id": 1, "result": hex(2 ** 256 - 1)} + + venue = Venue(**{"RPC eth_call": call}) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.fetch_balance(), await azuro.fetch_allowance() + + balance, allowance = run(go()) + assert balance.total == Decimal("12.5") and balance.currency == "USDT" and balance.locked is None + assert allowance > Decimal(10) ** 60 + + def test_the_fee_is_the_relayers(self): + venue = Venue(**{GAS: [{"relayerFeeAmount": "15000"}]}) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.fetch_fee_estimate(f"azuro:{CORNERS}", Side.BUY, Decimal("0.5"), Decimal("10")) + + fee = run(go()) + assert fee.taker_fee == Decimal("0.015") and fee.currency == "USDT" + + def test_a_cash_out_is_quoted_signed_and_sent(self): + quote = {"calculationId": "PolygonUSDT_0xf39f_472100", "owner": OWNER.address, "environment": "PolygonUSDT", + "betId": "472100", "cashoutAmount": "8.1", "cashoutOdds": "1.62", "expiredAt": 1_800_000_000, + "approveExpiredAt": 1_800_000_000, "isLive": False} + venue = Venue(**{"POST /api/v1/public/cashout/get-calculation": quote, + "POST /api/v1/public/cashout/create": {"id": "c1", "state": "PROCESSING"}}) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.cash_out(472100) + + assert run(go())["state"] == "PROCESSING" + asked = json.loads(venue.sent("POST /api/v1/public/cashout/get-calculation")[0].content) + assert asked == {"environment": "PolygonUSDT", "owner": OWNER.address, "betId": "472100"} + body = json.loads(venue.sent("POST /api/v1/public/cashout/create")[0].content) + signature = body["signature"] + assert body["calculationId"] == quote["calculationId"] + assert signature["verifyingContract"] == POLYGON.cashout and signature["bettingContract"] == POLYGON.core + message = sig.cashout_message(POLYGON, bet_id=472100, cashout_odds="1.62", expires_at=1_800_000_000) + encoded = encode_typed_data(full_message=sig.cashout_typed_data(POLYGON, message)) + assert EthAccount.recover_message(encoded, signature=signature["ownerSignature"]) == OWNER.address + + def test_no_cash_out_offered(self): + async def go(): + async with adapter(Venue()) as azuro: + assert await azuro.fetch_cashout_quote(1) is None + await azuro.cash_out(1) + + with pytest.raises(OrderRejected, match="no cash-out"): + run(go()) + + def test_a_discontinued_chain_takes_no_bets(self): + with pytest.raises(synpath.BadRequest, match="discontinued"): + AzuroTrading(AzuroCredentials(private_key=KEY, chain="gnosis"), catalog=Catalog()) + + def test_base_bets_in_weth(self): + venue = Venue(**{GAS: {"relayerFeeAmount": "1"}, PLACE: {"id": "x", "state": "Created"}}) + + async def go(): + async with adapter(venue, chain="base") as azuro: + return await azuro.create_order(request(quantity=Decimal("0.01"))) + + run(go()) + body = json.loads(venue.sent(PLACE)[0].content) + assert body["environment"] == "BaseWETH" and body["clientBetData"]["bet"]["amount"] == str(5 * 10 ** 15) + assert body["clientBetData"]["clientData"]["core"] == CHAINS["base"].core + + +class TestCredentials: + def test_a_key_alone_bets_on_polygon(self): + creds = load_azuro({"AZURO_PRIVATE_KEY": KEY[2:]}) + assert creds.private_key == KEY and creds.chain == "polygon" and creds.affiliate is None + assert creds.address == OWNER.address and KEY not in repr(creds) and creds.secrets == [KEY] + + def test_nothing_configured(self): + assert load_azuro({}) is None + + @pytest.mark.parametrize("env, match", [ + ({"AZURO_PRIVATE_KEY": "0x1234"}, "64 hex"), + ({"AZURO_PRIVATE_KEY": KEY, "AZURO_CHAIN": "gnosis"}, "discontinued"), + ({"AZURO_PRIVATE_KEY": KEY, "AZURO_AFFILIATE": KEY}, "private key"), + ]) + def test_bad_values(self, env, match): + with pytest.raises(CredentialsMissing, match=match) as raised: + load_azuro(env) + assert KEY[2:] not in str(raised.value) + + def test_the_chain_affiliate_and_rpc(self): + creds = load_azuro({"AZURO_PRIVATE_KEY": KEY, "AZURO_CHAIN": "base-sepolia", "AZURO_AFFILIATE": "0x" + "11" * 20, + "AZURO_RPC_URL": "https://rpc.example"}) + assert (creds.chain, creds.affiliate, creds.rpc_url) == ("base_sepolia", "0x" + "11" * 20, "https://rpc.example") + + def test_loaded_with_every_venue_and_routed_by_the_client(self): + loaded = load_credentials({"AZURO_PRIVATE_KEY": KEY}, redact_logs=False) + assert isinstance(loaded["azuro"], AzuroCredentials) + trading = synpath.Client(loaded).trading("azuro") + assert isinstance(trading, AzuroTrading) and trading.chain.name == "polygon" + run(trading.close()) + + def test_doctor_names_the_wallet_and_chain(self, tmp_path, monkeypatch, capsys): + from synpath.trading.__main__ import doctor + + monkeypatch.chdir(tmp_path) + for name in [n for n in __import__("os").environ if n.startswith(("AZURO_", "SX_BET_"))]: + monkeypatch.delenv(name, raising=False) + (tmp_path / ".env").write_text(f"AZURO_PRIVATE_KEY={KEY}\nAZURO_CHAIN=base\n") + assert doctor(str(tmp_path / ".env")) == 0 + out = capsys.readouterr().out + line = next(l for l in out.splitlines() if l.startswith("azuro")) + assert "configured (base)" in line and OWNER.address in line and KEY[2:] not in out diff --git a/tests/test_azuro_ws.py b/tests/test_azuro_ws.py new file mode 100644 index 0000000..7d08e12 --- /dev/null +++ b/tests/test_azuro_ws.py @@ -0,0 +1,203 @@ +"""The Azuro feed stream: subscriptions, quotes, one-level books and status +changes, through a fake socket. The frames follow the feed's documented +`ConditionUpdated` shape and the venue's SDK (`@azuro-org/sdk` 8.2.0), which +read `id`, `state` and each outcome's `outcomeId` and `currentOdds`; the feed's +socket refuses this test machine's region, so none was recorded.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +from synpath.types import OrderBook, OrderLevel +from synpath.ws.azuro import AzuroMarketStream +from synpath.ws.base import BookEvent, MarketStatusEvent, QuoteEvent, StreamStatusEvent + +CORNERS = "600610060000000000943019940000000000001802657415" +THREE_WAY = "600610060000000000943019940000000000001406934671" + + +def update(condition_id, state="Active", **odds): + return json.dumps({"event": "ConditionUpdated", "data": { + "id": condition_id, "conditionId": condition_id, "state": state, "gameId": "1006000000000094301994", + "outcomes": [{"outcomeId": int(k[1:]), "currentOdds": v, "state": "Active"} for k, v in odds.items()], + }}) + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +class FakeSocket: + def __init__(self): + self.sent: list[dict] = [] + + async def send(self, text): + self.sent.append(json.loads(text)) + + +class Catalog: + def __init__(self): + self.read: list[str] = [] + + def fetch_order_book(self, native): + self.read.append(native) + return OrderBook(market_id=f"azuro:{native}", venue="azuro", bids=[OrderLevel(price=0.39393939, size=16.5)], + asks=[OrderLevel(price=0.49019608, size=150.0)], info={"limits": {}}) + + +def run(stream, coroutine=None, *frames): + async def go(): + stream._ws = FakeSocket() + if coroutine is not None: + await coroutine(stream) + for raw in frames: + stream._dispatch(raw) + await asyncio.sleep(0) + for _ in range(3): + await asyncio.sleep(0) + return stream._ws.sent + + return asyncio.run(go()) + + +class TestSubscriptions: + def test_it_subscribes_each_condition_once_on_the_chains_environment(self): + stream = AzuroMarketStream(chain="base") + assert stream.url == "wss://streams.onchainfeed.org/v1/streams/feed" + + async def go(s): + await s.watch_ticker([f"azuro:{CORNERS}", f"azuro:{THREE_WAY}-1", f"azuro:{THREE_WAY}-2"]) + await s.watch_market_status([f"azuro:{CORNERS}"]) + + sent = run(stream, go) + assert sent == [{"event": "SubscribeConditions", + "data": {"conditionIds": sorted([CORNERS, THREE_WAY]), "environment": "BaseWETH"}}] + + def test_a_testnet_uses_the_dev_feed(self): + assert AzuroMarketStream(chain="polygon_amoy").url.startswith("wss://dev-streams.onchainfeed.org/") + + def test_a_reconnect_subscribes_everything_again(self): + stream = AzuroMarketStream() + + async def go(s): + await s.watch_ticker([f"azuro:{CORNERS}"]) + s.on_disconnect() + await s.on_connect() + + sent = run(stream, go) + assert len(sent) == 2 and sent[1]["data"]["conditionIds"] == [CORNERS] + + def test_unwatch_unsubscribes_a_condition_no_market_still_needs(self): + stream = AzuroMarketStream() + + async def go(s): + await s.watch_ticker([f"azuro:{THREE_WAY}-1", f"azuro:{THREE_WAY}-2"]) + await s.unwatch([f"azuro:{THREE_WAY}-1"]) + await s.unwatch([f"azuro:{THREE_WAY}-2"]) + + sent = run(stream, go) + assert [m["event"] for m in sent] == ["SubscribeConditions", "UnsubscribeConditions"] + assert sent[1]["data"] == {"conditionIds": [THREE_WAY], "environment": "PolygonUSDT"} + + +class TestUpdates: + def test_an_update_is_the_pools_quote_on_yes(self): + stream = AzuroMarketStream() + run(stream, lambda s: s.watch_ticker([f"azuro:{CORNERS}"]), update(CORNERS, o1="2.04", o2="1.65")) + [quote] = [e for e in drain(stream) if isinstance(e, QuoteEvent)] + assert quote.market_id == f"azuro:{CORNERS}" and quote.ask == Decimal("0.49019608") + assert quote.bid == Decimal(str(round(1 - 1 / 1.65, 8))) + + def test_an_update_naming_one_outcome_keeps_the_others_odds(self): + stream = AzuroMarketStream() + run(stream, lambda s: s.watch_ticker([f"azuro:{CORNERS}"]), + update(CORNERS, o1="2.04", o2="1.65"), update(CORNERS, o1="2.5")) + quotes = [e for e in drain(stream) if isinstance(e, QuoteEvent)] + assert quotes[-1].ask == Decimal("0.4") and quotes[-1].bid == quotes[0].bid + + def test_one_outcome_of_a_larger_condition_has_only_an_ask(self): + stream = AzuroMarketStream() + run(stream, lambda s: s.watch_ticker([f"azuro:{THREE_WAY}-1"]), update(THREE_WAY, o1="4.95", o2="1.32", o3="6.83")) + [quote] = [e for e in drain(stream) if isinstance(e, QuoteEvent)] + assert quote.ask == Decimal(str(round(1 / 4.95, 8))) and quote.bid is None + + def test_other_conditions_are_ignored(self): + stream = AzuroMarketStream() + run(stream, lambda s: s.watch_ticker([f"azuro:{CORNERS}"]), update(THREE_WAY, o1="2")) + assert not [e for e in drain(stream) if isinstance(e, QuoteEvent)] + + def test_a_stop_and_a_resolution_are_status_changes(self): + stream = AzuroMarketStream() + won = json.dumps({"event": "ConditionUpdated", "data": {"id": CORNERS, "state": "Resolved", "wonOutcomeIds": ["2"], + "outcomes": []}}) + run(stream, lambda s: s.watch_market_status([f"azuro:{CORNERS}"]), + update(CORNERS, o1="2.04", o2="1.65"), update(CORNERS, state="Stopped"), won) + states = [(e.state, e.result) for e in drain(stream) if isinstance(e, MarketStatusEvent)] + assert states == [("open", None), ("paused", None), ("settled", "no")] + + def test_a_stopped_condition_quotes_nothing(self): + stream = AzuroMarketStream() + run(stream, lambda s: s.watch_ticker([f"azuro:{CORNERS}"]), update(CORNERS, state="Stopped", o1="2.04", o2="1.65")) + [quote] = [e for e in drain(stream) if isinstance(e, QuoteEvent)] + assert quote.bid is None and quote.ask is None + + def test_garbage_is_ignored(self): + stream = AzuroMarketStream() + run(stream, None, "not json", json.dumps({"event": "Other"}), json.dumps([1, 2])) + assert not [e for e in drain(stream) if not isinstance(e, StreamStatusEvent)] + + +class TestBooks: + def test_a_book_is_seeded_with_the_pools_limits_then_moved_by_updates(self): + catalog = Catalog() + stream = AzuroMarketStream(catalog=catalog) + run(stream, lambda s: s.watch_order_book([f"azuro:{CORNERS}"]), update(CORNERS, o1="2.5", o2="1.65")) + books = [e for e in drain(stream) if isinstance(e, BookEvent)] + assert catalog.read == [CORNERS] and books[0].info["seeded"] + assert books[0].best_ask == Decimal("0.49019608") and books[0].asks[0].size == Decimal("150.0") + assert books[-1].best_ask == Decimal("0.4") and books[-1].asks[0].size == Decimal("150.0") + local = stream.book(f"azuro:{CORNERS}") + assert local.ready and local.best_bid == books[-1].best_bid + assert stream.book(f"azuro:{CORNERS}", "no").best_ask == 1 - local.best_bid + + def test_a_disconnect_invalidates_the_book_and_a_reconnect_reads_it_again(self): + catalog = Catalog() + stream = AzuroMarketStream(catalog=catalog) + + async def go(s): + await s.watch_order_book([f"azuro:{CORNERS}"]) + s.on_disconnect() + assert not s.book(f"azuro:{CORNERS}").ready + await s.on_connect() + for _ in range(5): + await asyncio.sleep(0.01) + + run(stream, go) + events = drain(stream) + assert catalog.read == [CORNERS, CORNERS] and stream.book(f"azuro:{CORNERS}").ready + assert any(isinstance(e, StreamStatusEvent) and e.state == "resynced" for e in events) + + def test_limits_that_cannot_be_read_say_so(self): + class Refusing: + def fetch_order_book(self, native): + from synpath import ExchangeNotAvailable + raise ExchangeNotAvailable("azuro: unavailable (503)") + + stream = AzuroMarketStream(catalog=Refusing()) + run(stream, lambda s: s.watch_order_book([f"azuro:{CORNERS}"]), update(CORNERS, o1="2.5", o2="1.65")) + events = drain(stream) + assert any(isinstance(e, StreamStatusEvent) and e.state == "error" for e in events) + assert not [e for e in events if isinstance(e, BookEvent)] and not stream.book(f"azuro:{CORNERS}").ready + + def test_the_engine_gets_the_stream(self): + from synpath.engine.feeds import default_streams + from synpath.trading.credentials import AzuroCredentials + + creds = AzuroCredentials(private_key="0x" + "11" * 32, chain="base") + streams = default_streams({"azuro": object()}, {"azuro": creds}) + assert isinstance(streams["azuro"].market, AzuroMarketStream) and streams["azuro"].private is None + assert streams["azuro"].market.chain.name == "base" diff --git a/tests/test_init.py b/tests/test_init.py index 775d89a..5fe01cc 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -42,7 +42,8 @@ def test_it_writes_every_venue_asked_for_readable_by_the_owner_only(tmp_path: Pa "n", # Limitless "n", # SX Bet "n", # Betfair - "n"], # Smarkets + "n", # Smarkets + "n"], # Azuro secrets=[KEY, "us-secret"], ) assert run(env, p) == 0 @@ -64,7 +65,7 @@ def test_wrong_values_are_asked_again_with_the_reason(tmp_path: Path): p, said = scripted( ["y", "", "kid-1", str(tmp_path / "missing.pem"), str(not_pem), str(pem), "staging", "demo", "y", "0x1234", WALLET, "7", "1", - "n", "n", "n", "n", "n", "n", "n", "n"], + "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -81,7 +82,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) env = tmp_path / ".env" env.write_text("# mine\nSOMETHING_ELSE=keep\nPOLYMARKET_US_KEY_ID=old\n") os.chmod(env, 0o644) - p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) + p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) assert run(env, p) == 0 text = env.read_text() assert "# mine" in text and "SOMETHING_ELSE=keep" in text @@ -91,7 +92,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) def test_saying_no_to_everything_writes_nothing(tmp_path: Path): - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) assert run(tmp_path / ".env", p) == 0 assert not (tmp_path / ".env").exists() and any("Nothing changed." in line for line in said) @@ -104,7 +105,7 @@ def test_merge_quotes_values_with_spaces_and_updates_in_place(): @pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) + p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) assert run(tmp_path / ".env", p) == 0 assert (tmp_path / ".gitignore").read_text() == ".env\n" ignored = subprocess.run(["git", "check-ignore", "-q", ".env"], cwd=tmp_path) @@ -113,7 +114,7 @@ def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): def test_opinion_takes_a_key_it_is_given(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["OPINION_PRIVATE_KEY"] == KEY and values["OPINION_API_KEY"] == "opinion-api-key" @@ -122,7 +123,7 @@ def test_opinion_takes_a_key_it_is_given(tmp_path: Path): def test_opinion_with_an_api_key_needs_no_private_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"OPINION_API_KEY": "opinion-api-key"} assert load_credentials({}, dotenv=env, redact_logs=False)["opinion"] is None, "not enough to trade" @@ -135,7 +136,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch signed_with: list[str] = [] monkeypatch.setattr(opinion, "create_api_key", lambda key: signed_with.append(key) or "created-key") - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) assert run(tmp_path / ".env", p) == 0 assert signed_with == [KEY], "creating a key needs the private key: an empty one is asked again" assert read_dotenv(tmp_path / ".env")["OPINION_API_KEY"] == "created-key" @@ -144,7 +145,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY and values["HYPERLIQUID_ACCOUNT_ADDRESS"] == WALLET @@ -156,7 +157,7 @@ def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY @@ -165,7 +166,7 @@ def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_PRIVATE_KEY"] == KEY and values["PREDICT_FUN_ACCOUNT_ADDRESS"] == WALLET @@ -178,7 +179,7 @@ def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"PREDICT_FUN_API_KEY": "pred_sk_test"} text = " ".join(said) @@ -190,7 +191,7 @@ def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_TESTNET"] == "1" and "PREDICT_FUN_API_KEY" not in values @@ -201,7 +202,7 @@ def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): env = tmp_path / ".env" secret = "c2VjcmV0LWJ5dGVz" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n"], secrets=[KEY[2:], secret]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) assert run(env, p) == 0 values = read_dotenv(env) assert values["LIMITLESS_PRIVATE_KEY"] == KEY and values["LIMITLESS_API_TOKEN_ID"] == "token-123" @@ -212,7 +213,7 @@ def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n"], secrets=["sx-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["SX_BET_PRIVATE_KEY"] == KEY and values["SX_BET_API_KEY"] == "sx-api-key" @@ -223,7 +224,7 @@ def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n"], secrets=["sx-api-key", ""]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key", "SX_BET_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said), "a key pasted as the address is refused" @@ -234,7 +235,7 @@ def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): def test_sx_bet_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n"], secrets=["sx-api-key", ""]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key"} loaded = load_credentials({}, dotenv=env, redact_logs=False)["sx_bet"] From 5afe7f111c5e410a3c8803736497a7170f16ccf3 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 18:00:00 +0100 Subject: [PATCH 07/19] feat: add myriad Co-Authored-By: Claude Opus 5.5 --- .env.example | 15 + README.md | 9 +- docs/api.md | 20 +- docs/capabilities.md | 111 ++- docs/streaming.md | 15 + docs/trading.md | 61 +- synpath/__init__.py | 10 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 14 + synpath/ids.py | 8 +- synpath/myriad.py | 1111 +++++++++++++++++++++++++ synpath/server/serve.py | 2 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 17 +- synpath/trading/credentials.py | 79 +- synpath/trading/init.py | 24 + synpath/trading/instruments.py | 4 + synpath/trading/myriad.py | 938 +++++++++++++++++++++ synpath/trading/myriad_signing.py | 299 +++++++ synpath/types.py | 21 + synpath/ws/__init__.py | 5 +- synpath/ws/myriad.py | 563 +++++++++++++ tests/samples/myriad_amm_events.json | 1 + tests/samples/myriad_book_no.json | 1 + tests/samples/myriad_book_yes.json | 1 + tests/samples/myriad_event.json | 1 + tests/samples/myriad_grouped.json | 1 + tests/samples/myriad_market.json | 1 + tests/samples/myriad_market_amm.json | 1 + tests/samples/myriad_markets.json | 1 + tests/samples/myriad_markets_amm.json | 1 + tests/samples/myriad_not_found.json | 1 + tests/samples/myriad_quote_buy.json | 1 + tests/samples/myriad_quote_sell.json | 1 + tests/samples/myriad_trades.json | 1 + tests/samples/myriad_ws.json | 24 + tests/test_init.py | 61 +- tests/test_myriad.py | 346 ++++++++ tests/test_myriad_live.py | 52 ++ tests/test_myriad_trading.py | 550 ++++++++++++ tests/test_myriad_trading_live.py | 30 + tests/test_myriad_ws.py | 287 +++++++ tests/test_myriad_ws_live.py | 29 + 44 files changed, 4666 insertions(+), 64 deletions(-) create mode 100644 synpath/myriad.py create mode 100644 synpath/trading/myriad.py create mode 100644 synpath/trading/myriad_signing.py create mode 100644 synpath/ws/myriad.py create mode 100644 tests/samples/myriad_amm_events.json create mode 100644 tests/samples/myriad_book_no.json create mode 100644 tests/samples/myriad_book_yes.json create mode 100644 tests/samples/myriad_event.json create mode 100644 tests/samples/myriad_grouped.json create mode 100644 tests/samples/myriad_market.json create mode 100644 tests/samples/myriad_market_amm.json create mode 100644 tests/samples/myriad_markets.json create mode 100644 tests/samples/myriad_markets_amm.json create mode 100644 tests/samples/myriad_not_found.json create mode 100644 tests/samples/myriad_quote_buy.json create mode 100644 tests/samples/myriad_quote_sell.json create mode 100644 tests/samples/myriad_trades.json create mode 100644 tests/samples/myriad_ws.json create mode 100644 tests/test_myriad.py create mode 100644 tests/test_myriad_live.py create mode 100644 tests/test_myriad_trading.py create mode 100644 tests/test_myriad_trading_live.py create mode 100644 tests/test_myriad_ws.py create mode 100644 tests/test_myriad_ws_live.py diff --git a/.env.example b/.env.example index 0934e99..c922f17 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,21 @@ SX_BET_PRIVATE_KEY= SX_BET_ACCOUNT_ADDRESS= SX_BET_TESTNET= +# Myriad: market data needs nothing. MYRIAD_PRIVATE_KEY is the wallet your +# Myriad account is connected to: it signs every order and cancel. An API key +# and its secret (Myriad Account settings; the secret is shown once) sign the +# account's requests and raise the rate limit; the venue refuses a key without +# its secret. Without a key the wallet signs in for a session instead, and a +# wallet's first sign-in creates its Myriad account. A key alone reads the +# account: set MYRIAD_ACCOUNT_ADDRESS (the wallet's public 0x address, not its +# private key). MYRIAD_RPC_URL (optional) is the BNB Chain node the balance is +# read from. +MYRIAD_PRIVATE_KEY= +MYRIAD_API_KEY= +MYRIAD_API_SECRET= +MYRIAD_ACCOUNT_ADDRESS= +MYRIAD_RPC_URL= + # Betfair Exchange (market data and streams; no order entry yet): every call, # market data included, needs your own account and application key. The free # delayed key (1-180 s late, three price levels) is on the developer program diff --git a/README.md b/README.md index 649c0d2..4e780ca 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Synpath – One API for prediction markets Follow @Synpath_Dev on X **Build on prediction markets without building against each one.**
-One open-source API across 8 venues (Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet): market data, order entry, live streams, and an execution engine that remembers what it sent. In-process from Python, over REST and WebSocket from anywhere. +One open-source API across 9 venues (Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Myriad): market data, order entry, live streams, and an execution engine that remembers what it sent. In-process from Python, over REST and WebSocket from anywhere. License: MIT Total Downloads @@ -80,16 +80,19 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [predict.fun](https://predict.fun) | `predict_fun` | ✓ (API key) | ✓ | WebSocket (API key) | one book per market, both sides read it | | | [Limitless](https://limitless.exchange) | `limitless` | ✓ | ✓ | WebSocket | one book per market, both sides read it | | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | +| | [Myriad](https://myriad.markets) | `myriad` | ✓ order-book and AMM markets, sports | ✓ order book | WebSocket (no key) | one book per market, both sides read it; AMM books built from the pool | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). +Myriad runs order-book markets on BNB Chain and AMM (pool) markets on BNB Chain, Abstract and Linea behind one API. A market id carries its network, and `amm` for a pool market, since the two kinds number their markets separately: `myriad:56-2385` is an order-book market, `myriad:56-amm-2385` an unrelated pool market. Order entry is on the order book; a pool trade is an on-chain transaction this library does not send. See [capabilities](docs/capabilities.md#myriad). + Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). ## Why Synpath -- **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. +- **Liquidity is fragmented. Your time shouldn't be.** The same market trades on Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Myriad, each with its own API, units and quirks. Synpath gives you one interface for all of them, and smart order routing buys from whichever book is cheapest. Spend your time on alpha, not plumbing. - **Traders deserve advanced order types.** Stops, trailing stops, icebergs, OCO, brackets, TWAP and pegs, on every venue, even where the exchange has none. Orders are journaled before they're sent, so a crash never places one twice. @@ -135,7 +138,7 @@ fee.estimate(price=0.50, contracts=100) # 1.75 **Same code, every venue** ```python -for venue_id in synpath.exchanges: # [..., 'sx_bet', 'betfair', 'smarkets'] (Betfair and predict.fun need keys) +for venue_id in synpath.exchanges: # [..., 'sx_bet', 'myriad', 'betfair', 'smarkets'] (Betfair and predict.fun need keys) with synpath.exchange(venue_id) as venue: page = venue.fetch_markets(limit=5) diff --git a/docs/api.md b/docs/api.md index dfbf90e..b289b64 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,7 +77,7 @@ and spot exchanges do not. | Method | Returns | Notes | |---|---|---| | `fetch_markets(query, limit, cursor, status, sort)` | `Page[Market]` | `limit` up to 100 | -| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read), order preserved, closed and settled markets included | +| `fetch_markets_by_ids(ids)` | `list[Market]` | batched (Kalshi 200, Polymarket 100, Polymarket US 50 ids a request; Opinion has no batch lookup, one request each; Hyperliquid answers any number from one catalog read; predict.fun and Limitless, one request each; SX Bet 30 ids a request; Myriad 100 a request per network and kind of market; Betfair 100 markets a catalogue request and 40 a book request; Smarkets 300 markets a lookup and 100 a contracts read), order preserved, closed and settled markets included | | `fetch_events(query, limit, cursor, status)` | `Page[Event]` | markets nested | | `fetch_market(market_id)` | `Market` | | | `fetch_order_book(market_id, side, depth)` | `OrderBook` | `side="yes"` (default) or `"no"` | @@ -122,6 +122,22 @@ book.info["bids"], book.info["offers"] # Smarkets' own [price bps, quantity] smk.fetch_trades(contracts[0].id) # the short recent tape ``` +Myriad needs no key (an API key and secret raise its rate limit). It takes +`topic` (`"sports"`, `"crypto"`), `tag` (a league, team or asset slug), +`in_play`, `trading_model` (`"all"`, the default, `"ob"` or `"amm"`) and +`network` (56 BNB Chain, 2741 Abstract, 59144 Linea) on `fetch_markets` and +`fetch_events`, and adds `fetch_event(id)`, `fetch_topics()`, `fetch_tags(type)` +and `raw_market(id)`. Any id also takes the market's slug. See +[Myriad](capabilities.md#myriad). + +```python +myriad = synpath.Myriad() # no key needed +games = myriad.fetch_markets(topic="sports", trading_model="ob", limit=9) +book = myriad.fetch_order_book(games[0].id, side="no") # outcome 1's own book +pool = myriad.fetch_order_book("myriad:56-amm-120473") # built from the pool: book.info["depth_source"] == "amm" +myriad.fetch_fee_schedule(games[0].id).estimate(0.40, 100) # the taker fee at 40c +``` + Polymarket also has `fetch_order_books_by_token(token_ids, depth)`, by CLOB token id. @@ -262,7 +278,7 @@ venue to interpret for itself. ### Identifiers -One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). +One id per market and per event, `venue:native` (`kalshi:KXELONMARS-99`, `polymarket:2252244`). On Betfair a market is a runner, `betfair:-` with `-` added when it is not zero (`betfair:1.234567890-47972`, `betfair:1.234567892-2426--0.5`), and its event the Betfair market, `betfair:1.234567890`. On Smarkets a market is a contract, `smarkets:-` (`smarkets:164973231-456060183`), and its event the Smarkets market, `smarkets:164973231`. On Myriad a market is `myriad:-` on an order book (`myriad:56-2385`) and `myriad:-amm-` on a pool (`myriad:56-amm-120473`), since the two number their markets separately; one outcome of a pool with more than two is `myriad:-amm--`, and that pool its event. A venue event is `myriad:`. The full rules are on [Synpath IDs](https://synpath.dev/docs/concepts/ids). ### Paging diff --git a/docs/capabilities.md b/docs/capabilities.md index b521b2a..df4f05e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -6,21 +6,21 @@ Read `has` before you call. A capability a venue lacks raises `NotSupported` rather than returning an empty list, so "cannot" is never mistaken for "there is nothing". -| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `smarkets` | -|---|---|---|---|---|---|---|---|---|---|---| -| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | -| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | -| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | -| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | -| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | -| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | -| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | -| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | -| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | -| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, Betfair's text search | yes, Smarkets' event search | -| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | -| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | -| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `myriad` | `betfair` | `smarkets` | +|---|---|---|---|---|---|---|---|---|---|---|---| +| `fetch_markets` | yes | yes | yes | yes, categorical topics flattened into their options | yes, every outcome of a question is a market, the fallback ("Other") included | yes, quoted from the listing | yes, a group's options flattened; quoted from the venue's prices, bid and ask on `refresh_quotes` | yes, outcome one as YES, under the venue's names for both sides; bid and ask on `refresh_quotes` | yes, order-book and AMM markets, outcome 0 as YES under the venue's names; a pool with more than two outcomes flattened into one market per outcome; points markets left out; filter by topic, tag, in-play, trading model or network | yes, each runner a market, soonest start first, with its best offers; filter by event type, competition, event or market type | yes, each contract a market, Smarkets events in id order; quotes, last executed price and market volume attached; filter by domain, scope or market type, no login | +| `fetch_events` | yes | yes | yes | yes, one per topic | yes, one per question; a standalone outcome is its own event | yes, one per category | yes, one per group; a standalone market is its own event | yes, one per fixture, one league a page | yes, the venue's events; every other market is its own event | yes, one per Betfair market (all its runners); the fixture is `info["betfair_event"]` | yes, one per Smarkets market (all its contracts); the fixture is `info["smarkets_event"]` | +| `fetch_market` | yes | yes | yes | yes; a categorical option costs a second read for its topic | yes, from the cached catalog | yes | yes; a group option costs a second read for its question | yes, open or settled | yes, open or settled, by id or slug | yes, open or closed; a closed market is read from its book alone (no names, but the result) | yes, open or settled | +| `fetch_order_book` | yes | yes | yes | yes | yes, top 20 levels a side; NO mirrored from YES | yes; NO mirrored from YES | yes; NO mirrored from YES | yes; orders on outcome two read as YES asks; NO mirrored | yes; NO is outcome 1's own book, which holds what YES orders imply; an AMM market's built from its pool, one level a cent | yes, best offers (3 levels on a delayed key); resting backs are YES bids, resting lays asks; NO mirrored | yes, the venue's quotes: bids are YES bids, offers YES asks; delayed without a login; NO mirrored | +| `fetch_order_books` (batched) | yes, one request per market | yes, one round trip | yes, one request per market | yes, one request per market | yes, one request per market | yes, one request per 50 | yes, one request per market | yes, one request per market | yes, one request per market | yes, forty Betfair markets a request (the weight limit) | yes, two hundred Smarkets markets a request | +| `fetch_trades` | yes | yes | no public tape over REST; the last trade is on the book | no public tape; the last trade is on `refresh_quotes` | partial: the venue's last few prints only, no history | yes, the venue's matches | yes, the venue's settled fills | yes, the venue's tape of taker bets | yes: on a book one trade per taker fill; on a pool its buys and sells | no: Betfair publishes no individual trades, only volume per price and the last traded price | partial: the venue's short recent tape per contract (a handful of trades), no paging | +| `fetch_ohlcv` | yes, the venue's candles | yes, built from the trade tape, both tokens folded into the YES price; the venue's quote samples via `source="quotes"` | partial: quote-derived bid/ask midpoints, no volume; no public tape to build from | partial: last-trade samples, hourly or daily, no volume | yes, the venue's traded candles with volume; 6h built from 2h | partial: the venue's probability samples, no volume | partial: the venue's price samples over fixed lookbacks, no volume | partial: built from the tape of taker bets, with volume; at most 2,000 bets back | partial: the venue's price samples (5 minutes over a day, 30 over a week, 4 hours beyond), no volume | no: no trades to build from | no: the venue's executions time series answers `CHARTS_UNAVAILABLE` | +| `fetch_series` | yes | no series tier | yes, but fees are per market, not per series | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | no series tier | +| `fetch_fee_schedule` | yes | yes | yes | yes, read from the venue's fee contract on BNB Chain | yes, at the lowest volume tier; charged on closing fills only | yes, taker only | yes, the venue's buy-fee curve, taker only | yes, the venue's default rates: 1% of a winning taker's profit | yes: a book's taker table by price (3 to 180 bps of the notional), makers free; a pool's share of the value | yes, commission at the market's base rate on net market winnings; discount and Expert Fee not included | yes, 2% (the standard tier) on net market winnings; tier and promotional rates not included | +| `search` | yes ([undocumented host](api.md#search)) | yes | yes | partial: no venue search; titles matched here over 25 catalog pages | yes, matched here over the whole catalog | yes, at the venue | yes, at the venue | yes, fixtures by team name at the venue (at most eight) | yes, at the venue (titles, descriptions, outcomes) | yes, Betfair's text search | yes, Smarkets' event search | +| `fetch_markets_by_ids` | yes | yes | yes | yes, one request per market | yes, one catalog read | yes, one request per market | yes, one request per market | yes, 30 a request | yes, a hundred a request per network and kind of market | yes, a hundred Betfair markets a catalogue request, forty a book request | yes, three hundred markets a lookup, a hundred a contracts read | +| `sort` | yes, the page is ordered after it is read | yes, at the venue | yes, the page is ordered after it is read; `volume` and `liquidity` cost one read per market | partial: `volume` and `newest` at the venue; no liquidity figure exists | partial: `volume` and `newest`; no liquidity figure exists | partial: `volume` only | partial: `newest` only | no: the venue publishes no volume, liquidity or listing time | yes, at the venue (24-hour volume, liquidity, publication time) | partial: `volume` (matched) and `liquidity` (available) at the venue, one page; no listing time | no: the venue lists by id, start time or display order only | +| `match_market` / `match_event` | no | no | no | no | no | no | no | no | no | no | no | Polymarket US is the CFTC-regulated exchange, not the on-chain CLOB the `polymarket` adapter reads; the two share a brand and nothing else. It has @@ -77,6 +77,57 @@ cancelled game, a spread landing on the line), which returns every bet; ladder. Fees are charged on winnings: 1% of a taker's profit on a winning bet, nothing for a maker. No key for market data. +### Myriad + +Myriad is two venues behind one API. *Order-book* markets (BNB Chain, in USD1) +trade EIP-712 orders that the venue's matcher settles on chain; *AMM* markets +(BNB Chain in USD1 or USDT, Abstract in USDC.e, Linea in USDC) trade against a +fixed-product pool, one contract call per trade. Both are read here: every +listing asks the venue for both kinds (its default is AMM only), and +`trading_model="ob"` or `"amm"` narrows it. Markets in Myriad's points token +(PTS) are not money and are left out, and Celo, where every market is in +points, is not read. + +The two kinds number their markets separately, so the same number on BNB +Chain is two unrelated markets; the id carries the network and the kind: +`myriad:56-2385` (order book), `myriad:56-amm-2385` (pool). A market's slug +works wherever an id does. An order-book market is binary, outcome 0 YES and +outcome 1 NO, under the venue's own names (`Patriots` and `Bears` on a game); +the venue's events (`myriad:`) group several, mutually exclusive when the +venue marks them neg-risk. A two-outcome pool is one binary market, outcome 0 +YES; a pool with more outcomes is an event of its own, one binary market per +outcome (`myriad:2741-amm-412-3`), NO being the rest of the pool. Many markets +name the Polymarket market they follow (`info["polymarket_ids"]`). Sports +markets carry the venue's scoreboard (`info["scoreboard"]`), whether they are +in play, and their teams and leagues as tags; `fetch_markets(topic="sports", +tag="nfl", in_play=True)` filters at the venue. + +Order-book prices are whole cents and sizes 18-decimal shares. The venue +serves each outcome's book with what the other outcome's orders imply already +in it (a NO bid at 0.94 is a YES ask at 0.06), so `side="no"` reads outcome 1's +own book. A pool has no orders: its book is built from the pool's balances, +one level per cent of price, each the shares a trade moving the price into that +cent would fill at that slice's average price, fees included +(`info["depth_source"] == "amm"`). The quote on a pool market is its price as +`mid`, and the marginal buy and sell prices, after fees, as `ask` and `bid`. + +The order-book tape lists every leg of a match, the taker's and each maker's; +only a taker pays a fee, so each taker leg is a trade here, and a match where +no leg paid one is reported once with its side unknown. Splits and merges of +positions are not trades and are left out. A pool's tape is its buys and sells. +Candles are the venue's price samples per outcome: 5-minute over the last day, +30-minute over a week, 4-hour beyond. + +Fees: an order-book taker pays a share of the notional that depends on the +price, from the market's own table of basis points by cent (3 bps at 1c, 180 +at 50c; `bps_by_price`); a maker pays nothing. A pool charges a share of the +value traded on each side, set per market (`share_of_value`), often zero. + +No key for market data, at 30 requests per 10 seconds; an API key and its +secret (`Myriad(api_key=..., api_secret=...)`) raise that to 200 a second, and +then every request is HMAC-signed, since the venue refuses a bare key. Check +Myriad's terms for where it may be used before trading. + ### Betfair Betfair Exchange is a betting exchange, read as YES/NO markets. A Betfair @@ -209,20 +260,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | -|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | -| `rfq` | yes | no | no | no | no | no | no | no | no | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `myriad` | +|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | partial: the account's history names neither the order nor the fee | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's taker table at the price of the outcome bought | +| `rfq` | yes | no | no | no | no | no | no | no | no | no | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -256,6 +307,8 @@ Every stream answers the `watch_*` keys the same way | `LimitlessUserStream` | no | no | no | no | yes | yes | no | no | | `SxBetMarketStream` | yes | partial | yes | yes | no | no | no | no | | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | +| `MyriadMarketStream` | yes, order books, both outcomes | yes | yes | partial: markets created and resolved | no | no | no | no | +| `MyriadUserStream` | no | no | no | no | yes | yes | partial: one outcome's balance per update | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | diff --git a/docs/streaming.md b/docs/streaming.md index 7a577f6..1a46426 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -41,6 +41,8 @@ asyncio.run(main()) | `LimitlessUserStream` | `ws.limitless.exchange` | a Limitless API token | orders; fills as matched, then mined or failed on chain | | `SxBetMarketStream` | `realtime.sx.bet` (or the testnet) | SX Bet API key (every channel needs one) | whole books, top of book, the tape of taker bets, market changes and settlements | | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | +| `MyriadMarketStream` | `ws.myriadprotocol.com/ws` | none | order books (both outcomes), top of book and last per outcome, the tape of matches, markets created and resolved | +| `MyriadUserStream` | `ws.myriadprotocol.com/ws` | the wallet's address, and a subscription token per account channel (`MyriadTrading.subscription_token`) | orders; fills from the wallet's public tape; share balances | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | @@ -64,6 +66,7 @@ variable `synpath serve` reads it from: | Polymarket | nothing | | | Hyperliquid | nothing | | | Limitless | nothing | | +| Myriad | nothing | | | Opinion | the Open API key | `OPINION_API_KEY` | | predict.fun | the API key (`pred_sk_...`, from developers.predict.fun); mainnet only | `PREDICT_FUN_API_KEY` | | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | @@ -171,6 +174,18 @@ jittered exponential backoff between attempts. grows, and a quiet market publishes nothing, so each book is seeded from the REST snapshot when its subscription is confirmed. Trades and market changes are venue-wide channels, filtered here to the markets asked for. +- *Myriad* speaks Centrifugo's JSON protocol too, anonymously: the books, + prices, trades and markets channels are public, and so is one wallet's tape + on every market (`trades:56:*:`), which carries the account's fills. + The account's orders and positions channels each take a short-lived token + that the REST API issues for the signed-in wallet's own channels; the user + stream asks the trading adapter for one per channel on every subscribe. A + book delta is the new size of each changed level, for both outcomes at once; + both books are seeded from REST when the subscription is confirmed, and + deltas that arrived first are replayed on the snapshot, which is safe since + each is a level's absolute size. A reconnect reseeds them. Publications are + coalesced into windows of about 100 ms. Order-book markets only: pools + publish nothing here. - *Betfair* is not a WebSocket: its Exchange Stream API is one TLS socket to `stream-api.betfair.com:443` carrying CRLF-delimited JSON. The stream authenticates with the application key and a session token straight away diff --git a/docs/trading.md b/docs/trading.md index 27a5edb..7913c2b 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -24,7 +24,7 @@ pip install synpath ``` Order entry ships in the base package. The install carries the venues' signing -stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet, +stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet and Myriad, Ed25519 and private-key JWT for Polymarket US. Nothing further is needed to place an order. Polymarket and Opinion orders are hashed and signed by the Rust core @@ -71,6 +71,7 @@ private key. `synpath init` tells them apart. | predict.fun | mainnet: the API key (`pred_sk_...`). Test network: nothing | the API key and a private key: a plain wallet's, or a Predict account's owner key with its deposit address | the key at developers.predict.fun; a web-app account's owner key under Account -> Settings -> Privy wallet | | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | +| Myriad | nothing (an API key and secret raise the rate limit) | the private key of the wallet the Myriad account is connected to (it signs every order and cancel), and an API key with its secret, or nothing more: without a key the wallet signs in (SIWE) for a session token. A key alone reads the account, with `MYRIAD_ACCOUNT_ADDRESS` | Myriad Account settings, or `MyriadTrading.create_api_key()` from a signed-in session; the secret is shown once | For Opinion, predict.fun and SX Bet the API key alone is a complete market-data setup: `synpath init` asks for it first and takes an empty @@ -94,6 +95,7 @@ The variables each venue reads: | Hyperliquid | `HYPERLIQUID_PRIVATE_KEY` (an API wallet's key is safest: it trades but cannot withdraw), `HYPERLIQUID_ACCOUNT_ADDRESS` (the account an API wallet trades for; not needed with the account's own key), `HYPERLIQUID_TESTNET` (`1` for the test network) | | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | +| Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, @@ -573,3 +575,60 @@ each market on the YES leg. The balance is the proxy wallet's USDC: what is free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. + +## Myriad + +`MyriadTrading`, built to Myriad's API reference, for its order book on BNB +Chain. Order hashes and signatures are checked against an independent +EIP-712 encoder and fixed known answers. + +Every order is the venue's nine-field EIP-712 `Order` (trader, market, +outcome, side, amount, price, minimum fill, nonce, expiration), signed by the +wallet under the domain `MyriadCTFExchange` on chain 56; its digest is the +order's hash, the id every lookup and cancel takes, known before it is sent. +Requests to the account's endpoints are signed with the API key and its secret +(HMAC-SHA256 over the timestamp, method, path with query, and body), or, +without a key, carry a session token the wallet signs in for (SIWE: the +venue's login message signed as a personal message; the session is renewed +before it lapses). The first sign-in of a wallet creates its Myriad account +and ties it to that wallet; orders must be that wallet's own. + +An order trades one outcome of a binary market. `buy` buys outcome 0 (YES) at +the price given; `sell` buys outcome 1 at `1 - price`; with `reduce_only`, +`sell` sells outcome 0 held and `buy` sells outcome 1 held. Prices are whole +cents and sizes shares to 18 decimals. Limits rest until cancelled (`gtc`), or +until `expires_at` (`gtd`), post-only (`PO`) on request; `market` and `ioc` go +as the venue's fill-and-kill (`FAK`) at the price given as the worst accepted, +`fok` as its fill-or-kill. The matcher settles fills on chain after the order +is accepted, so what an order filled is read from the order, its stream, or +the account's history. `create_orders` sends up to 200 a request, and the +venue checks the collateral of a batch at match time rather than on arrival. +A client order id is sent only with an API key, as the venue requires. + +A cancel repeats the order and its signature. Orders placed through the +adapter are remembered; any other is read back from the venue and signed +again, which gives the same signature. `cancel_all_orders` is one signed +`CancelAll`, for a market or the whole account. `edit_order` is the venue's +atomic cancel-and-place: the matcher never sees both orders, and the queue +place is lost. + +Fees are taker-only: a share of the notional by price, from the market's own +table (3 bps at 1c, 180 bps at 50c). Before the first order the wallet +approves USD1 and its outcome shares to the exchange: `approval_calls()` +returns the two transactions for the wallet to send. `position_calldata()` +returns the venue's calldata to split collateral into YES and NO, merge them +back, or redeem after resolution. + +Orders are read by status (`open`, `filled`, `cancelled`, `expired`). The +account's trade history names neither the order behind a fill nor its fee, +so `fetch_my_trades` is partial; `MyriadUserStream` carries orders, fills +from the wallet's public tape with their orders and fees, and share balances +([Streaming](streaming.md)). Positions come from the venue's portfolio, order +book and AMM alike, netted per binary market on the YES leg. The balance is +the wallet's USD1 on BNB Chain, less what open buy orders reserve. + +AMM markets trade through on-chain contract calls (the venue's quote route +returns the calldata), one per trade with gas, on BNB Chain, Abstract and +Linea; they are not traded here, and an order for one is refused. + +Check Myriad's terms for where it may be used before trading. diff --git a/synpath/__init__.py b/synpath/__init__.py index b43731a..2b15b1d 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -47,6 +47,7 @@ from .predict_fun import PredictFun from .limitless import Limitless from .sx_bet import SxBet +from .myriad import Myriad from .betfair import Betfair from .smarkets import Smarkets from .matching import match_event, match_market @@ -93,6 +94,7 @@ "predict_fun": PredictFun, "limitless": Limitless, "sx_bet": SxBet, + "myriad": Myriad, "betfair": Betfair, "smarkets": Smarkets, } @@ -115,7 +117,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, MyriadTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -145,7 +147,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "MyriadMarketStream", "MyriadUserStream", "BetfairMarketStream", "SmarketsMarketStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -167,7 +169,7 @@ def __getattr__(name: str): __all__ = [ - "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Betfair", "Smarkets", "Exchange", "exchange", "exchanges", + "Kalshi", "Polymarket", "PolymarketUS", "Opinion", "Hyperliquid", "PredictFun", "Limitless", "SxBet", "Myriad", "Betfair", "Smarkets", "Exchange", "exchange", "exchanges", "Capability", "HttpClient", "RateLimiter", "Market", "Event", "Outcome", "Quote", "MarketStats", "OrderBook", "OrderLevel", "Trade", "Candle", "FeeSchedule", "Series", "Page", @@ -184,7 +186,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "MyriadTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index 20f063a..0e90045 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -383,7 +383,13 @@ def _sx_bet(creds: Credentials) -> TradingExchange: return SxBetTrading(creds) # type: ignore[arg-type] +def _myriad(creds: Credentials) -> TradingExchange: + from .trading.myriad import MyriadTrading + return MyriadTrading(creds) # type: ignore[arg-type] + + _TRADING = { "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, + "myriad": _myriad, } diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..5f08be4 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -65,6 +65,7 @@ "predict_fun": ("..trading.predict_fun", "PredictFunTrading"), "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), + "myriad": ("..trading.myriad", "MyriadTrading"), } diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..b6283a5 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -153,6 +153,20 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] market=SxBetMarketStream(api_key=creds.api_key, testnet=testnet, catalog=catalog), private=SxBetUserStream(api_key=creds.api_key, testnet=testnet, address=address) if address else None, ) + elif venue == "myriad" and creds is not None: + # Books are public; the account's orders and positions channels take a + # subscription token the adapter signs for, fills the public per-wallet tape. + from ..ws.myriad import MyriadMarketStream, MyriadUserStream + catalog = getattr(adapter, "catalog", None) + address = getattr(creds, "address", None) + token = getattr(adapter, "subscription_token", None) + if not address: + log.warning("synpath.engine.feeds: myriad has an API key but no wallet address, so its orders " + "reach the engine by polling; set MYRIAD_ACCOUNT_ADDRESS for the account stream") + out[venue] = VenueStreams( + market=MyriadMarketStream(catalog=catalog), + private=MyriadUserStream(address=address, token=token) if address and token else None, + ) elif venue == "betfair" and creds is not None: # Market data only: no Betfair order entry, so no account stream. # Every Betfair stream, books included, logs in with the account. diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..129e884 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -4,8 +4,10 @@ the venue's own id, separated by a colon: `kalshi:KXFEDDECISION-26SEP-C25`, `polymarket:2252244`, `polymarket_us:tec-mlb-nlchamp-2026-09-27-atl`, `opinion:5342`, `hyperliquid:7173`, `betfair:1.234567890-47972`, -`smarkets:164973231-456060183`. Nothing is hashed or re-keyed, so the part -after the colon is what the venue's own website and API call the thing. +`smarkets:164973231-456060183`, `myriad:56-2385` (a network and a market +number, which Myriad's API takes together; `myriad:56-amm-2385` for an AMM +market). Nothing is hashed or re-keyed, so the part after the colon is what +the venue's own website and API call the thing. The venue in the id is what lets one field route a call: `synpath.Client` reads the prefix and hands the call to that venue's adapter. Every venue @@ -16,7 +18,7 @@ """ from __future__ import annotations -VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") +VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "myriad", "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.""" diff --git a/synpath/myriad.py b/synpath/myriad.py new file mode 100644 index 0000000..56b601c --- /dev/null +++ b/synpath/myriad.py @@ -0,0 +1,1111 @@ +"""Myriad: an onchain prediction market on BNB Chain, Abstract and Linea, read from its REST API. + +Market data needs no key. An API key and its secret raise the rate limit from +30 requests per 10 seconds to 200 a second; every keyed request is then +HMAC-signed, because the venue refuses a bare key. + +Five things about this venue that shape the adapter: + +**Two kinds of market behind one API.** An *order book* market (BNB Chain +only, USD1) trades signed orders that a matcher settles onchain; an *AMM* +market (BNB Chain, Abstract, Linea) trades against a fixed-product pool, one +onchain transaction per trade. Every read here asks for both +(`trading_model=all`); the venue's default is AMM only. + +**A market id is unique per network and per kind.** The same number names an +order-book market and an unrelated AMM market on BNB Chain, so the native id +carries both: `myriad:56-2385` is order-book market 2385 on BNB Chain (56), +`myriad:56-amm-2385` the AMM market with that number, and +`myriad:2741-amm-213` an AMM market on Abstract. A market's slug is accepted +wherever an id is. + +**Binary markets, YES first.** An order-book market has two outcomes, `0` +(YES) and `1` (NO), even when the venue names them after teams; an event +(`myriad:`) groups several of them, mutually exclusive when the venue +marks it neg-risk. An AMM market with more than two outcomes is an event of +its own, and each outcome is a binary market on it +(`myriad:59144-amm-12-3` is outcome 3): YES is that outcome, NO the rest of +the pool. A two-outcome AMM market is one binary market, outcome `0` YES. + +**Books.** An order-book outcome's book is served per outcome and already +includes what the other outcome's orders imply (a NO bid at 0.94 is a YES ask +at 0.06), so `side="no"` reads outcome 1's book rather than mirroring. +Prices and sizes are 18-decimal integers. An AMM market has no resting orders: +its book here is built from the pool, one level per cent of price, each the +shares a trade moving the price that far would fill, fees included +(`info["depth_source"] == "amm"`). + +**Fees.** An order-book taker pays a share of the notional that depends on +the price, published per market as a table of basis points by cent: 3 bps at +1c, 180 bps at 50c (`bps_by_price`). Makers pay nothing. An AMM market +charges a share of the value traded, set per market (`share_of_value`). + +Markets denominated in Myriad's points token (PTS) are not money and are left +out of listings, and Celo, where every market is in points, is not read. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterator + +import httpx + +from . import ids +from .base import ( + MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort, check_status, + page_limit, pick_bars, timeframe_seconds, +) +from .errors import BadRequest, ExchangeError, MarketNotFound +from .types import ( + BookSide, Candle, Event, FeeSchedule, Market, MarketStats, OrderBook, OrderLevel, Outcome, + Page, Quote, Trade, iso, +) + +API_URL = "https://api-v2.myriadprotocol.com" +VENUE = "myriad" +WEB_URL = "https://myriad.markets/markets/" + +LIMITER = RateLimiter(2.5, burst=5) +"""30 requests per 10 seconds per IP without a key, shared process-wide.""" + +KEYED_LIMITER = RateLimiter(100.0, burst=100) +"""With an API key the venue allows 200 a second; half of it, shared.""" + +NETWORKS: dict[int, str] = {56: "BNB Chain", 2741: "Abstract", 59144: "Linea"} +"""The networks read here. Celo (42220) lists only points markets.""" + +ORDER_BOOK_NETWORK = 56 +"""Order-book markets run on BNB Chain only.""" + +POINTS = "PTS" +"""Myriad's points token. Markets in it are not money.""" + +PAGE = 100 +"""Rows per listing page: the venue's maximum.""" + +MAX_PAGES = 40 +"""Most listing pages one walk reads: 4,000 rows, against about 400 open today.""" + +SCALE = 10**18 +"""Order-book prices are 1e18 fractions of a token; sizes are in the token's +smallest unit, and every order-book market's token (USD1) has 18 decimals.""" + +OB_DECIMALS = 18 + +TICK = 0.01 +"""Order-book prices are whole cents.""" + +STATES = {"open": "open", "closed": "closed", "resolved": "settled"} +"""The venue's market state as this library's status.""" + +STATUS_PARAM = {"open": "open", "closed": "closed", "settled": "resolved", "all": None} + +SORT_BY = {"volume": "volume_24h", "liquidity": "liquidity", "newest": "published_at"} +"""This library's sort keys as the venue's own.""" + +CHARTS = (("24h", 86400), ("7d", 7 * 86400), ("30d", 30 * 86400)) +"""The price-chart timeframes the venue keeps per outcome, shortest first: +5-minute, 30-minute and 4-hour samples. `all` covers the rest, at 4 hours.""" + +DEFAULT_BARS = 96 + +NATIVE_ID = re.compile(r"^(?P\d+)-(?:(?Pamm)-)?(?P\d+)(?:-(?P\d+))?$") + + +@dataclass(frozen=True) +class Ref: + """What a native id names: a market on a network, of one kind, and for a + many-outcome AMM market, the outcome.""" + + network: int + market: int + amm: bool = False + outcome: int | None = None + + @property + def model(self) -> str: + return "amm" if self.amm else "ob" + + @property + def native(self) -> str: + return native_id(self.network, self.market, amm=self.amm, outcome=self.outcome) + + @property + def market_native(self) -> str: + """The id of the whole venue market, without an outcome.""" + return native_id(self.network, self.market, amm=self.amm) + + +def native_id(network: Any, market: Any, *, amm: bool, outcome: int | None = None) -> str: + """`56-2385`, `56-amm-2385`, `2741-amm-412-3`.""" + head = f"{int(network)}-{'amm-' if amm else ''}{int(market)}" + return head if outcome is None else f"{head}-{int(outcome)}" + + +def parse_id(native: str) -> Ref | None: + """A native id's parts, or `None` for anything else (a slug).""" + found = NATIVE_ID.match(native) + if not found: + return None + outcome = found.group("outcome") + if outcome is not None and not found.group("amm"): + raise BadRequest(f"{VENUE}: {native!r}: an order-book market is binary; it has no outcome suffix") + return Ref(int(found.group("network")), int(found.group("market")), amm=bool(found.group("amm")), + outcome=int(outcome) if outcome is not None else None) + + +def to_float(value: Any) -> float | None: + try: + return float(value) if value not in (None, "") else None + except (TypeError, ValueError): + return None + + +def iso_ms(value: Any) -> int | None: + """An ISO timestamp, or unix seconds, as epoch milliseconds.""" + if value in (None, ""): + return None + if isinstance(value, (int, float)) or str(value).isdigit(): + number = int(value) + return number * 1000 if number < 10**11 else number + try: + return int(datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return None + + +def units(value: Any, decimals: int = OB_DECIMALS) -> float | None: + """An amount the venue writes in the token's smallest unit (`"1000000000000000000"`) + or, on some routes, already in tokens (`"98.00000000"`), as tokens.""" + if value in (None, ""): + return None + text = str(value) + try: + if "." in text or "e" in text.lower(): + return float(text) + return int(text) / 10**decimals + except ValueError: + return None + + +def scaled_price(value: Any) -> float | None: + """A 1e18-scaled price as a fraction.""" + if value in (None, ""): + return None + try: + return int(str(value)) / SCALE + except ValueError: + return to_float(value) + + +def is_amm(raw: dict[str, Any]) -> bool: + return str(raw.get("tradingModel") or "").lower() == "amm" or raw.get("executionMode") == 0 + + +def is_points(raw: dict[str, Any]) -> bool: + return str((raw.get("token") or {}).get("symbol") or "").upper() == POINTS + + +def ref_of(raw: dict[str, Any], outcome: int | None = None) -> Ref: + return Ref(int(raw["networkId"]), int(raw["id"]), amm=is_amm(raw), outcome=outcome) + + +def status_of(raw: dict[str, Any]) -> tuple[str, str, bool]: + """(normalized status, native word, accepting orders).""" + native = str(raw.get("state") or "") + status = STATES.get(native, "closed") + return status, native, status == "open" + + +def tags_of(raw: dict[str, Any]) -> list[str]: + """The market's topics and tag slugs, lowercase, without duplicates.""" + words = [str(t) for t in raw.get("topics") or []] + for tag in raw.get("tags") or []: + if isinstance(tag, dict): + words.append(str(tag.get("slug") or tag.get("title") or "")) + elif tag: + words.append(str(tag)) + seen: dict[str, None] = {} + for word in words: + if word.strip(): + seen.setdefault(word.strip().lower(), None) + return list(seen) + + +def settlement_sources_of(raw: dict[str, Any]) -> list[dict[str, Any]]: + """The oracle the venue names, and the resolution source when it differs.""" + sources: list[dict[str, Any]] = [] + oracle = raw.get("oracle") or {} + if oracle.get("name"): + sources.append({"name": str(oracle["name"]), "url": oracle.get("url") or None}) + source = str(raw.get("resolutionSource") or "").strip() + if source and all(s.get("url") != source for s in sources): + sources.append({"name": str(raw.get("resolutionTitle") or source), "url": source if source.startswith("http") else None}) + return sources + + +def amm_fees(raw: dict[str, Any]) -> tuple[float, float]: + """(buy, sell): an AMM market's whole fee on each side, as a share of the value.""" + fees = raw.get("fees") or {} + + def total(side: str) -> float: + part = fees.get(side) or {} + return sum(to_float(part.get(k)) or 0.0 for k in ("fee", "treasury_fee", "distributor_fee")) + + return total("buy"), total("sell") + + +def _clean(price: float | None) -> float | None: + """A quoted price inside (0, 1), or `None`: 0 and 1 are not quotes.""" + if price is None or not 0 < price < 1: + return None + return round(price, 6) + + +def _quote(bid: float | None, ask: float | None, *, mid: float | None = None) -> Quote: + bid, ask = _clean(bid), _clean(ask) + if bid is not None and ask is not None: + mid = round((bid + ask) / 2, 6) if mid is None else mid + else: + mid = None + return Quote(bid=bid, ask=ask, mid=_clean(mid) if mid is not None else None) + + +def amm_quote(price: float | None, *, buy_fee: float, sell_fee: float) -> Quote: + """A pool's price as the venue would trade one share at the margin: buying + costs `price / (1 - fee)`, selling returns `price * (1 - fee)`.""" + if price is None or not 0 < price < 1: + return Quote() + return _quote(price * (1 - sell_fee), price / (1 - buy_fee) if buy_fee < 1 else None, mid=price) + + +def complement(quote: Quote) -> Quote: + """The other side's view of a quote: bid and ask swap, at `1 - p`.""" + flip = (lambda p: round(1 - p, 6) if p is not None else None) + return Quote(bid=flip(quote.ask), ask=flip(quote.bid), mid=flip(quote.mid), + bid_size=quote.ask_size, ask_size=quote.bid_size) + + +def _outcome_info(outcome: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in outcome.items() if k != "price_charts"} + + +def normalize_market(raw: dict[str, Any], outcome: int | None = None) -> Market: + """One binary market: a two-outcome market as itself (outcome 0 YES), or + one outcome of a many-outcome AMM market (`outcome`), YES that outcome and + NO the rest of the pool.""" + outcomes = list(raw.get("outcomes") or []) + amm = is_amm(raw) + many = len(outcomes) > 2 + if many and outcome is None: + raise BadRequest(f"{VENUE}: market {raw.get('id')} has {len(outcomes)} outcomes; name one") + ref = ref_of(raw, outcome if many else None) + status, native_status, accepting = status_of(raw) + buy_fee, sell_fee = amm_fees(raw) if amm else (0.0, 0.0) + + def side_quote(o: dict[str, Any]) -> Quote: + if amm: + return amm_quote(to_float(o.get("price")), buy_fee=buy_fee, sell_fee=sell_fee) + return _quote(to_float(o.get("bestBid")), to_float(o.get("bestAsk"))) + + if many: + chosen = next((o for o in outcomes if int(o.get("id", -1)) == outcome), None) + if chosen is None: + raise MarketNotFound(f"{VENUE}: market {raw.get('id')} has no outcome {outcome}") + yes_quote = side_quote(chosen) + yes = Outcome(label=str(chosen.get("title") or f"Outcome {outcome}"), quote=yes_quote, + venue_token_id=str(chosen["tokenId"]) if chosen.get("tokenId") is not None else None, + price_change_24h=to_float(chosen.get("priceChange24h")), info=_outcome_info(chosen)) + no = Outcome(label=f"Not {yes.label}", quote=complement(yes_quote), info={"outcomes": "the rest of the pool"}) + title = f"{raw.get('title') or ''} — {yes.label}".strip(" —") + label: str | None = yes.label + event_native = ref.market_native + else: + first = outcomes[0] if outcomes else {} + second = outcomes[1] if len(outcomes) > 1 else {} + yes = Outcome(label=str(first.get("title") or "Yes"), quote=side_quote(first), + venue_token_id=str(first["tokenId"]) if first.get("tokenId") is not None else None, + price_change_24h=to_float(first.get("priceChange24h")), info=_outcome_info(first)) + no = Outcome(label=str(second.get("title") or "No"), quote=side_quote(second), + venue_token_id=str(second["tokenId"]) if second.get("tokenId") is not None else None, + price_change_24h=to_float(second.get("priceChange24h")), info=_outcome_info(second)) + title = str(raw.get("title") or raw.get("slug") or ref.native) + label = (raw.get("shortName") or None) if raw.get("eventId") else None + event_native = str(raw["eventId"]) if raw.get("eventId") else ref.native + opened, close = iso_ms(raw.get("publishedAt")), iso_ms(raw.get("expiresAt")) + resolves = iso_ms(raw.get("resolvesAt")) or close + tags = tags_of(raw) + token = raw.get("token") or {} + resolved = raw.get("resolvedOutcomeId") + winner: str | None = None + if status == "settled" and resolved is not None and int(resolved) >= 0 and not raw.get("voided"): + if many: + winner = "yes" if int(resolved) == outcome else "no" + else: + winner = {0: "yes", 1: "no"}.get(int(resolved)) + return Market( + id=ids.qualify(VENUE, ref.native), + venue=VENUE, + venue_market_id=ref.native, + event_id=ids.qualify(VENUE, event_native), + title=title, + description=raw.get("description") or None, + slug=raw.get("slug") or None, + yes=yes, + no=no, + status=status, # type: ignore[arg-type] + native_status=native_status or None, + active=accepting, + market_type="binary", + open_timestamp=opened, + open_datetime=iso(opened), + close_timestamp=close, + close_datetime=iso(close), + resolution_timestamp=resolves, + resolution_datetime=iso(resolves), + tick_size=None if amm else TICK, + face_value=1.0, + book_model="shared_complement", + stats=MarketStats( + volume_24h=to_float(raw.get("volume24h")), volume_total=to_float(raw.get("volume")), + liquidity=to_float(raw.get("liquidity")), volume_unit="collateral", liquidity_unit="collateral", + ), + url=f"{WEB_URL}{raw['slug']}" if raw.get("slug") else None, + image_url=raw.get("imageUrl") or None, + category=(tags or [None])[0], + tags=tags, + series_id=None, + outcome_label=label, + neg_risk=bool(raw.get("negRisk")) if raw.get("negRisk") is not None else None, + settlement_sources=settlement_sources_of(raw), + info={ + "market": {k: v for k, v in raw.items() if k not in ("description", "outcomes")}, + "network_id": ref.network, + "network": NETWORKS.get(ref.network), + "market_id": ref.market, + "trading_model": ref.model, + "outcome": outcome if many else None, + "outcomes": len(outcomes), + "token": token, + "points": is_points(raw), + "event_id": raw.get("eventId"), + "outcome_index": raw.get("outcomeIndex"), + "in_play": raw.get("inPlay"), + "in_play_starts_at": raw.get("inPlayStartsAt"), + "moneyline": raw.get("moneyline"), + "scoreboard": raw.get("scoreboard"), + "external_sources": raw.get("externalSources") or [], + "polymarket_ids": [str(s.get("externalMarketId")) for s in raw.get("externalSources") or [] + if str(s.get("providerName") or "").lower() == "polymarket" and s.get("externalMarketId")], + "voided": bool(raw.get("voided")), + "winning_outcome": winner, + }, + ) + + +def markets_of(raw: dict[str, Any]) -> list[Market]: + """The binary markets in one venue market: itself, or one per outcome of + a many-outcome AMM market.""" + outcomes = raw.get("outcomes") or [] + if len(outcomes) > 2: + return [normalize_market(raw, int(o["id"])) for o in outcomes if o.get("id") is not None] + return [normalize_market(raw)] if raw.get("id") is not None else [] + + +def normalize_event(row: dict[str, Any]) -> Event: + """An event row (`type: "event"`) with its markets, or a market row as an + event of its own: one market, or one per outcome of a many-outcome AMM market.""" + if row.get("type") == "event" or (row.get("markets") is not None and row.get("outcomes") is None): + markets = [m for child in row.get("markets") or [] for m in markets_of(child)] + native = str(row["id"]) + exclusive: bool | None = True if row.get("negRisk") else None + info = {k: v for k, v in row.items() if k not in ("markets", "description")} + else: + markets = markets_of(row) + native = ref_of(row).native + exclusive = True if len(row.get("outcomes") or []) > 2 else None + info = {k: v for k, v in row.items() if k not in ("description", "outcomes")} + status_word = str(row.get("state") or "") + statuses = {m.status for m in markets} + status = next((s for s in ("open", "closed", "settled") if s in statuses), STATES.get(status_word, "closed")) + close = iso_ms(row.get("expiresAt")) + tags = tags_of(row) or (markets[0].tags if markets else []) + return Event( + id=ids.qualify(VENUE, native), + venue=VENUE, + venue_event_id=native, + title=str(row.get("title") or row.get("slug") or native), + description=row.get("description") or None, + slug=row.get("slug") or None, + markets=markets, + status=status, # type: ignore[arg-type] + native_status=status_word or None, + category=(tags or [None])[0], + tags=tags, + mutually_exclusive=exclusive, + close_timestamp=close, + close_datetime=iso(close), + url=f"{WEB_URL}{row['slug']}" if row.get("slug") else None, + image_url=row.get("imageUrl") or None, + settlement_sources=markets[0].settlement_sources if markets else [], + info=info, + ) + + +# --------------------------------------------------------------------------- +# Books +# --------------------------------------------------------------------------- + +def book_levels(rows: Any, *, decimals: int = OB_DECIMALS) -> list[OrderLevel]: + """`[[price, amount], ...]` in 1e18 units as levels in fractions and shares.""" + out = [] + for row in rows or []: + if not isinstance(row, (list, tuple)) or len(row) < 2: + continue + price, size = scaled_price(row[0]), units(row[1], decimals) + if price is None or size is None or size <= 0: + continue + out.append(OrderLevel(price=round(price, 6), size=size)) + return out + + +def normalize_order_book( + payload: dict[str, Any], *, market_id: str, side: BookSide = "yes", depth: int | None = None, +) -> OrderBook: + """One outcome's book as the venue serves it: outcome 0's for YES, + outcome 1's for NO, each already holding what the other side's orders imply.""" + if side not in ("yes", "no"): + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + bids = sorted(book_levels(payload.get("bids")), key=lambda level: level.price, reverse=True) + asks = sorted(book_levels(payload.get("asks")), key=lambda level: level.price) + if depth: + bids, asks = bids[:depth], asks[:depth] + return OrderBook( + market_id=market_id, side=side, venue=VENUE, bids=bids, asks=asks, timestamp=None, datetime=None, + book_model="shared_complement", derived=False, depth_scope="top_n" if depth else "full", + info={"orderbook": payload, "outcome": 0 if side == "yes" else 1}, + ) + + +def _price(pools: list[float], index: int) -> float: + inverse = [1 / n for n in pools] + return inverse[index] / sum(inverse) + + +def _after_buy(pools: list[float], index: int, collateral: float) -> tuple[list[float], float]: + """Pools after `collateral` (net of fees) buys outcome `index`, and the shares it gets.""" + product = 1.0 + for n in pools: + product *= n + after = [n + collateral for n in pools] + rest = 1.0 + for j, n in enumerate(after): + if j != index: + rest *= n + after[index] = product / rest + return after, pools[index] + collateral - after[index] + + +def _after_sell(pools: list[float], index: int, collateral: float) -> tuple[list[float], float]: + """Pools after selling outcome `index` for `collateral` (before fees), and the shares it takes.""" + product = 1.0 + for n in pools: + product *= n + after = [n - collateral for n in pools] + rest = 1.0 + for j, n in enumerate(after): + if j != index: + rest *= n + after[index] = product / rest + return after, after[index] - (pools[index] - collateral) + + +def _solve(price_at: Any, target: float, low: float, high: float, *, rising: bool) -> float: + """The collateral at which the pool's price reaches `target`, by bisection.""" + for _ in range(80): + middle = (low + high) / 2 + reached = price_at(middle) + if (reached < target) == rising: + low = middle + else: + high = middle + return (low + high) / 2 + + +def amm_book_levels( + pools: list[float], index: int, *, buy_fee: float = 0.0, sell_fee: float = 0.0, tick: float = TICK, +) -> tuple[list[OrderLevel], list[OrderLevel]]: + """A fixed-product pool as a book on outcome `index`: one level per `tick` + of price, each the shares a trade moving the price into that cent fills, + at that slice's average price with fees included. Bids best first, asks + best first.""" + if len(pools) < 2 or any(n <= 0 for n in pools) or not 0 <= index < len(pools): + return [], [] + start = _price(pools, index) + steps = round(1 / tick) + asks: list[OrderLevel] = [] + spent = shares = 0.0 + high = 1.0 + for cent in range(int(start / tick) + 1, steps): + target = cent * tick + while _price(_after_buy(pools, index, high)[0], index) < target: + high *= 2 + collateral = _solve(lambda c: _price(_after_buy(pools, index, c)[0], index), target, 0.0, high, rising=True) + got = _after_buy(pools, index, collateral)[1] + size, paid = got - shares, (collateral - spent) / (1 - buy_fee) + if size > 1e-12: + asks.append(OrderLevel(price=round(paid / size, 6), size=round(size, 9))) + spent, shares = collateral, got + bids: list[OrderLevel] = [] + received = sold = 0.0 + ceiling = min(n for j, n in enumerate(pools) if j != index) + for cent in range(int(-(-start // tick)) - 1, 0, -1): + target = cent * tick + collateral = _solve(lambda c: _price(_after_sell(pools, index, c)[0], index), target, 0.0, ceiling * (1 - 1e-12), + rising=False) + taken = _after_sell(pools, index, collateral)[1] + size, paid = taken - sold, (collateral - received) * (1 - sell_fee) + if size > 1e-12: + bids.append(OrderLevel(price=round(paid / size, 6), size=round(size, 9))) + received, sold = collateral, taken + return bids, asks + + +def amm_order_book( + raw: dict[str, Any], *, market_id: str, index: int, side: BookSide = "yes", depth: int | None = None, + native_no: bool = True, +) -> OrderBook: + """An AMM market's book on outcome `index`, built from its pool. For + `side="no"` on a two-outcome market (`native_no`), the other outcome's + own pool book; on one outcome of many, the YES book mirrored.""" + if side not in ("yes", "no"): + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + pools = [to_float(o.get("shares")) or 0.0 for o in raw.get("outcomes") or []] + buy_fee, sell_fee = amm_fees(raw) + derived = False + if side == "no" and native_no: + bids, asks = amm_book_levels(pools, 1 - index, buy_fee=buy_fee, sell_fee=sell_fee) + else: + bids, asks = amm_book_levels(pools, index, buy_fee=buy_fee, sell_fee=sell_fee) + if side == "no": + derived = True + bids, asks = ( + [OrderLevel(price=round(1 - level.price, 6), size=level.size) for level in asks], + [OrderLevel(price=round(1 - level.price, 6), size=level.size) for level in bids], + ) + if depth: + bids, asks = bids[:depth], asks[:depth] + return OrderBook( + market_id=market_id, side=side, venue=VENUE, bids=bids, asks=asks, timestamp=None, datetime=None, + book_model="shared_complement", derived=derived, depth_scope="top_n" if depth else "full", + info={"depth_source": "amm", "pools": pools, "buy_fee": buy_fee, "sell_fee": sell_fee, "outcome": index}, + ) + + +# --------------------------------------------------------------------------- +# Trades, candles, fees +# --------------------------------------------------------------------------- + +def _yes_leg(outcome: int, buying: bool, price: float) -> tuple[float, bool]: + """A trade on outcome 1 is the other side of YES at `1 - price`.""" + return (price, buying) if outcome == 0 else (1 - price, not buying) + + +def normalize_trades(rows: list[dict[str, Any]], *, market_id: str) -> list[Trade]: + """An order-book market's fills, one per taker, in the YES price. + + The venue lists every leg of a match, the taker's and each maker's, and + the splits and merges of positions beside them. Only the taker pays a + fee, so a leg with a fee is a taker's; a match where no leg paid one is + reported once per distinct price and size, its side unknown. Splits and + merges are not trades and are left out.""" + by_tx: dict[str, list[tuple[int, dict[str, Any]]]] = {} + for n, row in enumerate(rows): + if str(row.get("side") or "") in ("split", "merge"): + continue + by_tx.setdefault(str(row.get("txHash") or n), []).append((n, row)) + trades: list[Trade] = [] + for tx, legs in by_tx.items(): + takers = [(n, row) for n, row in legs if (units((row.get("fees") or {}).get("total")) or 0) > 0] + seen: set[tuple[float, float]] = set() + for position, (n, row) in enumerate(takers or legs): + price = to_float(row.get("price")) + quantity = units(row.get("amount")) + if price is None or quantity is None: + continue + yes_price, buying = _yes_leg(int(row.get("outcome") or 0), str(row.get("side")) == "buy", price) + if not takers: + key = (round(yes_price, 6), round(quantity, 8)) + if key in seen: + continue + seen.add(key) + stamp = iso_ms(row.get("timestamp")) or 0 + trades.append(Trade( + id=f"{tx}:{position}", market_id=market_id, timestamp=stamp, datetime=iso(stamp) or "", + price=round(yes_price, 6), quantity=quantity, + side=("buy" if buying else "sell") if takers else "unknown", info=row, + )) + return sorted(trades, key=lambda t: t.timestamp) + + +def normalize_amm_trade(row: dict[str, Any], *, market_id: str, index: int, many: bool) -> Trade | None: + """One pool trade (`/markets/:id/events`, `buy` or `sell`) in the YES price, + or `None` for anything else (liquidity, claims) and, on one outcome of + many, a trade on another outcome.""" + action = str(row.get("action") or "") + if action not in ("buy", "sell"): + return None + outcome = int(row.get("outcomeId") or 0) + shares, value = to_float(row.get("shares")), to_float(row.get("value")) + if not shares or value is None: + return None + buying = action == "buy" + if many: + if outcome != index: + return None + price = value / shares + else: + price, buying = _yes_leg(outcome, buying, value / shares) + stamp = iso_ms(row.get("timestamp")) or 0 + return Trade( + id=f"{row.get('txId') or stamp}:{outcome}:{action}", market_id=market_id, timestamp=stamp, + datetime=iso(stamp) or "", price=round(price, 6), quantity=shares, side="buy" if buying else "sell", + info={k: v for k, v in row.items() if k not in ("marketTitle", "imageUrl")}, + ) + + +def chart_for(seconds: int) -> str: + """The shortest price-chart timeframe covering `seconds`.""" + return next((name for name, span in CHARTS if span >= seconds), "all") + + +def candles_from_chart(points: list[dict[str, Any]], *, interval_seconds: int) -> list[Candle]: + """The venue's price samples for one outcome bucketed into bars, oldest + first. No volume behind them.""" + buckets: dict[int, list[tuple[int, float]]] = {} + for point in points: + stamp, price = iso_ms(point.get("timestamp")), to_float(point.get("value")) + if stamp is None or price is None: + continue + price = round(price, 6) + start = stamp // 1000 // interval_seconds * interval_seconds + buckets.setdefault(start, []).append((stamp, price)) + candles = [] + for start in sorted(buckets): + prices = [p for _, p in sorted(buckets[start])] + candles.append(Candle( + timestamp=start * 1000, datetime=iso(start * 1000) or "", + open=prices[0], high=max(prices), low=min(prices), close=prices[-1], + volume=None, trade_count=None, price_source="sampled_mid", info={"samples": len(prices)}, + )) + return candles + + +def fee_schedule_of(raw: dict[str, Any], *, market_id: str) -> FeeSchedule: + """An order-book market's taker table (`bps_by_price`), or an AMM + market's share of the value (`share_of_value`).""" + fees = raw.get("fees") or {} + if is_amm(raw): + buy, sell = amm_fees(raw) + return FeeSchedule( + venue=VENUE, scope="market", scope_id=market_id, fee_type="share_of_value", + taker_rate=buy, maker_rate=None, + info={"buy_rate": buy, "sell_rate": sell, "fees": fees, "currency": (raw.get("token") or {}).get("symbol")}, + ) + taker = [float(v) for v in fees.get("taker_fee_bps_array") or []] + maker = [float(v) for v in fees.get("maker_fee_bps_array") or []] + return FeeSchedule( + venue=VENUE, scope="market", scope_id=market_id, fee_type="bps_by_price", + taker_rate=max(taker) / 10_000 if taker else to_float(fees.get("taker_fee_bps")), + maker_rate=max(maker) / 10_000 if maker else (to_float(fees.get("maker_fee_bps")) or 0.0) / 10_000, + taker_bps_by_cent=taker or None, maker_bps_by_cent=maker or None, + info={"taker_fee_bps": fees.get("taker_fee_bps"), "maker_fee_bps": fees.get("maker_fee_bps"), + "currency": (raw.get("token") or {}).get("symbol")}, + ) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class Myriad(Exchange): + """Myriad, read-only. No key needed. + + ```python + import synpath + + myriad = synpath.Myriad() + markets = myriad.fetch_markets(limit=5, topic="sports") + book = myriad.fetch_order_book(markets[0].id) + ``` + """ + + id = VENUE + name = "Myriad" + book_model = "shared_complement" + has: dict[str, Capability] = { + "fetch_markets": True, + "fetch_events": True, + "fetch_market": True, + # One request per network and kind of market. + "fetch_markets_by_ids": True, + "sort": True, + "fetch_order_book": True, + # One request per market (an AMM book is built from its pool). + "fetch_order_books": True, + "fetch_trades": True, + # The venue's price samples per outcome, no volume. + "fetch_ohlcv": "partial", + "fetch_series": False, + "fetch_fee_schedule": True, + "search": True, + "match_market": False, + "match_event": False, + } + + def __init__( + self, *, api_key: str | None = None, api_secret: str | None = None, api_url: str = API_URL, + timeout: float = 30.0, limiter: RateLimiter | None = LIMITER, client: Any = None, + ): + """`api_key` and `api_secret` are optional: with both, every request + is HMAC-signed and the venue's limit rises to 200 a second (pass + `limiter=KEYED_LIMITER`, or one of your own, to use it). The venue + refuses a key without its signature.""" + if bool(api_key) != bool(api_secret): + raise BadRequest(f"{VENUE}: an API key needs its secret: the venue refuses unsigned keyed requests") + if client is None: + auth = None + if api_key and api_secret: + from .trading.myriad_signing import HmacAuth + + auth = HmacAuth(api_key, api_secret) + client = httpx.Client(timeout=timeout, follow_redirects=True, auth=auth) + self.http = HttpClient(api_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + + def _get(self, path: str, params: Any = None, *, what: str = "") -> Any: + try: + return self.http.get(path, params) + except MarketNotFound as exc: + raise MarketNotFound(f"{VENUE}: no {what or 'market'}", body=exc.body, status=exc.status) from None + + # -- catalog ------------------------------------------------------------ + + def _params( + self, *, status: str, sort: str | None, query: str | None, topic: Any, tag: Any, in_play: bool | None, + trading_model: str, network: Any, + ) -> dict[str, Any]: + if trading_model not in ("all", "ob", "amm"): + raise BadRequest(f"{VENUE}: trading_model is all, ob or amm, not {trading_model!r}") + networks = network if isinstance(network, (list, tuple)) else ([network] if network else list(NETWORKS)) + for net in networks: + if int(net) not in NETWORKS: + raise BadRequest(f"{VENUE}: network {net} is not read; one of {', '.join(map(str, NETWORKS))}") + return { + "trading_model": trading_model, + "state": STATUS_PARAM[status], + "network_id": ",".join(str(int(n)) for n in networks), + "sort": SORT_BY[sort] if sort else None, + "order": "desc" if sort else None, + "keyword": query or None, + "topics": _joined(topic, title=True), + "tags": _joined(tag), + "in_play": None if in_play is None else str(bool(in_play)).lower(), + } + + def _listing(self, params: dict[str, Any], page: int) -> tuple[list[dict[str, Any]], bool]: + """One listing page, and whether another follows.""" + payload = self._get("/markets", {**params, "page": page, "limit": PAGE}) or {} + rows = list(payload.get("data") or []) if isinstance(payload, dict) else [] + pagination = payload.get("pagination") or {} if isinstance(payload, dict) else {} + return rows, bool(rows) and bool(pagination.get("hasNext")) + + def fetch_markets( + self, *, query: str | None = None, limit: int | None = None, + cursor: str | None = None, status: str = "open", + sort: str | None = None, topic: str | list[str] | None = None, tag: str | list[str] | None = None, + in_play: bool | None = None, trading_model: str = "all", network: int | list[int] | None = None, + ) -> Page[Market]: + """One page of markets, a many-outcome AMM market counted outcome by + outcome; points markets left out. `sort` reads the venue's 24-hour + volume, liquidity or publication time. `query` is the venue's search + over titles, descriptions and outcomes. + + Venue filters: `topic` ("sports", "crypto", ...), `tag` (a tag slug: + a league, a team, an asset), `in_play` (sports markets live now), + `trading_model` (`all`, `ob` for order books, `amm`) and `network` + (56, 2741, 59144).""" + check_status(status) + check_sort(sort, venue=VENUE, supported=True) + params = self._params(status=status, sort=sort, query=query, topic=topic, tag=tag, in_play=in_play, + trading_model=trading_model, network=network) + wanted = page_limit(limit) or MAX_PAGE_LIMIT + page, skip = _cursor(cursor) + out: list[Market] = [] + for _ in range(MAX_PAGES): + rows, more = self._listing(params, page) + batch = [m for row in rows if not is_points(row) for m in markets_of(row)][skip:] + room = wanted - len(out) + out += batch[:room] + if len(batch) > room: + return Page(out, next_cursor=f"{page}:{skip + room}") + if not more: + return Page(out, next_cursor=None) + page, skip = page + 1, 0 + if len(out) >= wanted: + return Page(out, next_cursor=f"{page}:0") + return Page(out, next_cursor=f"{page}:0") + + def iter_markets(self, **kwargs: Any) -> Iterator[Market]: + """Every market the listing holds, page by page.""" + cursor: str | None = None + for _ in range(MAX_PAGES): + page = self.fetch_markets(cursor=cursor, **kwargs) + yield from page + cursor = page.next_cursor + if not cursor: + return + + def fetch_events( + self, *, query: str | None = None, limit: int | None = None, + cursor: str | None = None, status: str = "open", + sort: str | None = None, topic: str | list[str] | None = None, tag: str | list[str] | None = None, + in_play: bool | None = None, trading_model: str = "all", network: int | list[int] | None = None, + ) -> Page[Event]: + """One page of events: the venue's events with their markets, and + every other market as an event of its own (a many-outcome AMM market + holding one market per outcome). Filters as `fetch_markets`.""" + check_status(status) + check_sort(sort, venue=VENUE, supported=True) + params = self._params(status=status, sort=sort, query=query, topic=topic, tag=tag, in_play=in_play, + trading_model=trading_model, network=network) + # The cursor is `page:size`: a page number only means something at the size it was read with. + page, size = _cursor(cursor) + size = size or page_limit(limit) or PAGE + payload = self._get("/markets", {**params, "group_by_event": "true", "page": page, "limit": size}) or {} + rows = list(payload.get("data") or []) if isinstance(payload, dict) else [] + more = bool((payload.get("pagination") or {}).get("hasNext")) if isinstance(payload, dict) else False + events = [normalize_event(row) for row in rows if not is_points(row)] + return Page([e for e in events if e.markets], next_cursor=f"{page + 1}:{size}" if more and rows else None) + + def iter_events(self, **kwargs: Any) -> Iterator[Event]: + cursor: str | None = None + for _ in range(MAX_PAGES): + page = self.fetch_events(cursor=cursor, **kwargs) + yield from page + cursor = page.next_cursor + if not cursor: + return + + def fetch_event(self, event_id: str) -> Event: + """One event by id or slug: a venue event (`myriad:`), or a + market's own (`myriad:56-2385`, `myriad:2741-amm-412`).""" + native = self.native(event_id) + ref = parse_id(native) + if ref is not None: + return normalize_event(self.raw_market(native)) + return normalize_event({**(self._get(f"/events/{native}", what=f"event {native}") or {}), "type": "event"}) + + def raw_market(self, market_id: str) -> dict[str, Any]: + """The venue's own record of a market, by id or slug.""" + native = self.native(market_id) + ref = parse_id(native) + if ref is None: + raw = self._get(f"/markets/{native}", {"trading_model": "all"}, what=f"market {native}") + else: + raw = self._get(f"/markets/{ref.market}", {"network_id": ref.network, "trading_model": ref.model}, + what=f"market {ref.market_native}") + if not isinstance(raw, dict) or raw.get("id") is None: + raise MarketNotFound(f"{VENUE}: no market {native}") + if ref is not None and (int(raw.get("networkId") or 0) != ref.network or is_amm(raw) != ref.amm): + raise MarketNotFound(f"{VENUE}: no market {native}") + return raw + + def _resolve(self, market_id: str) -> tuple[Ref, dict[str, Any] | None]: + """A market id's parts; a slug is looked up (and its record returned).""" + native = self.native(market_id) + ref = parse_id(native) + if ref is not None: + return ref, None + raw = self.raw_market(native) + if len(raw.get("outcomes") or []) > 2: + raise MarketNotFound(f"{VENUE}: {native!r} has {len(raw['outcomes'])} outcomes, which makes it an " + f"event; its markets are {ref_of(raw).native}-") + return ref_of(raw), raw + + def fetch_market(self, market_id: str) -> Market: + """One market by id or slug, open or settled. A many-outcome AMM + market is an event; name one of its outcomes (`...-amm-412-3`).""" + ref, raw = self._resolve(market_id) + raw = raw or self.raw_market(ref.market_native) + outcomes = raw.get("outcomes") or [] + if len(outcomes) > 2 and ref.outcome is None: + raise MarketNotFound(f"{VENUE}: {ref.native} has {len(outcomes)} outcomes, which makes it an event; " + f"its markets are {ref.native}-") + return normalize_market(raw, ref.outcome if len(outcomes) > 2 else None) + + def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: + """Many markets in the order asked for: one request per network and + kind of market. Ids the venue does not know are left out.""" + refs: dict[str, Ref] = {} + for market_id in market_ids: + try: + ref, _ = self._resolve(market_id) + except MarketNotFound: + continue + refs[market_id] = ref + groups: dict[tuple[int, str], set[int]] = {} + for ref in refs.values(): + groups.setdefault((ref.network, ref.model), set()).add(ref.market) + found: dict[str, Market] = {} + for (network, model), numbers in groups.items(): + ordered = sorted(numbers) + for start in range(0, len(ordered), PAGE): + chunk = ordered[start:start + PAGE] + payload = self._get("/markets", {"ids": ",".join(map(str, chunk)), "network_id": network, + "trading_model": model, "limit": PAGE}) or {} + for row in payload.get("data") or [] if isinstance(payload, dict) else []: + for market in markets_of(row): + found[market.venue_market_id] = market + return [found[ref.native] for ref in refs.values() if ref.native in found] + + # -- market data -------------------------------------------------------- + + def fetch_order_book( + self, market_id: str, *, side: BookSide = "yes", depth: int | None = None, + ) -> OrderBook: + """An order-book market's book for one side, read from the venue; an + AMM market's, built from its pool (one request for the pool).""" + ref, raw = self._resolve(market_id) + qualified = self.qualify(ref.native) + if ref.amm: + raw = raw or self.raw_market(ref.market_native) + outcomes = raw.get("outcomes") or [] + many = len(outcomes) > 2 + if many and ref.outcome is None: + raise MarketNotFound(f"{VENUE}: {ref.native} is an event; name one of its outcomes") + return amm_order_book(raw, market_id=qualified, index=ref.outcome if many else 0, side=side, # type: ignore[arg-type] + depth=depth, native_no=not many) + if side not in ("yes", "no"): + raise BadRequest(f"{VENUE}: unknown side {side!r}; expected 'yes' or 'no'") + payload = self._get(f"/markets/{ref.market}/orderbook", + {"network_id": ref.network, "outcome": 0 if side == "yes" else 1}, + what=f"order book for {ref.native}") or {} + if not isinstance(payload, dict): + raise ExchangeError(f"{VENUE}: unexpected order book for {ref.native}") + return normalize_order_book(payload, market_id=qualified, side=side, depth=depth) + + def fetch_order_books( + self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None, + ) -> dict[str, OrderBook]: + """Books for many markets, one request each (the venue has no batch read).""" + books: dict[str, OrderBook] = {} + for market_id in dict.fromkeys(self.qualify(self.native(m)) for m in market_ids): + book = self.fetch_order_book(market_id, side=side, depth=depth) + books[book.market_id] = book + return books + + def fetch_trades( + self, market_id: str, *, since: int | None = None, limit: int | None = None, + cursor: str | None = None, + ) -> Page[Trade]: + """A page of the market's trades, newest first from the venue and + returned oldest first, in the YES price with the taker's side. On an + order book, one per taker fill (see `normalize_trades`); on an AMM, + the pool's buys and sells.""" + ref, raw = self._resolve(market_id) + qualified = self.qualify(ref.native) + page = int(cursor) if cursor and cursor.isdigit() else 1 + size = min(page_limit(limit) or 100, 100) + if ref.amm: + many = ref.outcome is not None + payload = self._get(f"/markets/{ref.market}/events", { + "network_id": ref.network, "trading_model": "amm", "page": page, "limit": size, + "since": since // 1000 if since else None, + }, what=f"trades for {ref.native}") or {} + rows = payload.get("data") or [] if isinstance(payload, dict) else [] + trades = [t for t in (normalize_amm_trade(r, market_id=qualified, index=ref.outcome or 0, many=many) + for r in rows) if t is not None] + more = bool((payload.get("pagination") or {}).get("hasNext")) if isinstance(payload, dict) else False + else: + payload = self._get(f"/markets/{ref.market}/trades", { + "network_id": ref.network, "page": page, "limit": size, + }, what=f"trades for {ref.native}") or [] + rows = payload if isinstance(payload, list) else (payload.get("data") or []) + trades = normalize_trades(rows, market_id=qualified) + more = len(rows) >= size + if since: + trades = [t for t in trades if t.timestamp >= since] + return Page(sorted(trades, key=lambda t: t.timestamp), next_cursor=str(page + 1) if more else None) + + def fetch_ohlcv( + self, market_id: str, *, timeframe: str = "1h", since: int | None = None, + until: int | None = None, limit: int | None = None, + ) -> list[Candle]: + """Bars in the YES price from the venue's price samples, no volume + (`price_source="sampled_mid"`): 5-minute samples over the last day, + 30-minute over a week, 4-hour beyond.""" + ref, raw = self._resolve(market_id) + seconds = timeframe_seconds(timeframe) + raw = raw or self.raw_market(ref.market_native) + now = _now_ms() + span = int(((until or now) - since) / 1000) if since else seconds * (limit or DEFAULT_BARS) + since_start = int((now - since) / 1000) if since else span + chart = chart_for(max(since_start, span, 1)) + index = ref.outcome if ref.outcome is not None else 0 + outcome = next((o for o in raw.get("outcomes") or [] if int(o.get("id", -1)) == index), {}) + series = next((c for c in outcome.get("price_charts") or [] if c.get("timeframe") == chart), {}) + candles = candles_from_chart(series.get("prices") or [], interval_seconds=seconds) + if since: + candles = [c for c in candles if c.timestamp >= since - seconds * 1000] + if until: + candles = [c for c in candles if c.timestamp <= until] + return pick_bars(candles, since=since, limit=limit) + + # -- reference ---------------------------------------------------------- + + def fetch_fee_schedule(self, market_id: str) -> FeeSchedule: + ref, raw = self._resolve(market_id) + return fee_schedule_of(raw or self.raw_market(ref.market_native), market_id=ref.native) + + def fetch_topics(self) -> list[dict[str, Any]]: + """The venue's topics with how many open markets each holds.""" + payload = self._get("/topics") or {} + return list(payload.get("data") or []) if isinstance(payload, dict) else [] + + def fetch_tags(self, *, type: str | None = None) -> list[dict[str, Any]]: + """The venue's tags on open markets (`type="league"`, "team", "sport", + "asset"), each with its market count. A tag's slug filters `fetch_markets(tag=...)`.""" + payload = self._get("/tags", {"type": type}) or {} + return list(payload.get("data") or []) if isinstance(payload, dict) else [] + + def close(self) -> None: + self.http.close() + + +def _joined(value: Any, *, title: bool = False) -> str | None: + """A filter value or list of them, comma-joined. Topics are the venue's + capitalised words ("Sports"), so a lowercase one is capitalised.""" + if value is None: + return None + items = [value] if isinstance(value, str) else list(value) + items = [str(v).strip() for v in items if str(v).strip()] + if title: + items = [v[:1].upper() + v[1:] for v in items] + return ",".join(items) or None + + +def _cursor(cursor: str | None) -> tuple[int, int]: + """`page:skip` — the listing page, and how many markets of it were already returned.""" + if not cursor: + return 1, 0 + try: + page, _, skip = cursor.partition(":") + return max(1, int(page)), max(0, int(skip or 0)) + except ValueError: + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") from None + + +def _now_ms() -> int: + return int(datetime.now(tz=timezone.utc).timestamp() * 1000) diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 6743665..135e3e7 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -35,7 +35,7 @@ log = logging.getLogger("synpath.serve") -VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet") +VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "myriad") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index aad1025..1dfd627 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -81,12 +81,15 @@ def __getattr__(name: str): if name == "SxBetTrading": from .sx_bet import SxBetTrading return SxBetTrading + if name == "MyriadTrading": + from .myriad import MyriadTrading + return MyriadTrading raise AttributeError(name) __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "MyriadTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index ab2f240..00dd655 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -29,6 +29,18 @@ def sx_bet_identity(creds: Any) -> str: return f"{wallet}; API key: market data, account, cancels; {placing}" +def myriad_identity(creds: Any) -> str: + """What Myriad's credentials allow: the private key signs orders and + cancels (and signs in without an API key); the API key and secret sign + requests, and alone read the account.""" + wallet = f"wallet {creds.address}" if creds.address else "wallet address unknown (set MYRIAD_ACCOUNT_ADDRESS)" + requests = (f"API key {creds.api_key[:8]}…: HMAC-signed requests" if creds.api_key + else "no API key: the wallet signs in for a session") + orders = ("private key loaded: places and cancels orders" if creds.private_key + else "no private key: reads the account only; set MYRIAD_PRIVATE_KEY to place or cancel") + return f"{wallet}; {requests}; {orders}" + + def doctor(dotenv: str | None) -> int: logging.basicConfig(level=logging.INFO, format="%(message)s") try: @@ -52,7 +64,7 @@ def doctor(dotenv: str | None) -> int: continue if creds is None: names = ENV_NAMES[venue] - hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair") else "" + hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "myriad", "betfair") else "" rows.append((venue, "not configured", f"set {names[0]}{hint}")) continue stage = getattr(creds, "env", "-") @@ -86,6 +98,9 @@ def doctor(dotenv: str | None) -> int: elif venue == "sx_bet": stage = "testnet" if creds.testnet else "mainnet" identity = sx_bet_identity(creds) + elif venue == "myriad": + stage = "mainnet" + identity = myriad_identity(creds) elif venue == "betfair": # The application key is a secret too; the username names the account. stage = creds.jurisdiction diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..4c31d90 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -11,7 +11,8 @@ Two sets per venue. `load_credentials` is what a trading adapter needs: the private key included, except for SX Bet, whose API key alone reads the -account and cancels and whose private key only places orders. +account and cancels and whose private key only places orders, and Myriad, +whose API key alone reads the account. `load_market_data` is the API key alone, for the three venues whose market data wants one and no wallet: Opinion, predict.fun and SX Bet. @@ -40,6 +41,10 @@ SX Bet SX_BET_API_KEY (market data, the account, cancels), and optionally SX_BET_PRIVATE_KEY (placing orders), SX_BET_ACCOUNT_ADDRESS, SX_BET_TESTNET + Myriad MYRIAD_PRIVATE_KEY (signs orders and cancels; signs in + when there is no API key), and optionally + MYRIAD_API_KEY with MYRIAD_API_SECRET (HMAC-signed + requests), MYRIAD_ACCOUNT_ADDRESS, MYRIAD_RPC_URL Betfair BETFAIR_APP_KEY, BETFAIR_USERNAME, BETFAIR_PASSWORD, and (market data) optionally BETFAIR_CERT_PATH / BETFAIR_KEY_PATH (certificate login) and BETFAIR_JURISDICTION @@ -316,6 +321,45 @@ def secrets(self) -> list[str]: return [self.api_key] + ([self.private_key] if self.private_key else []) +@dataclass(frozen=True, repr=False) +class MyriadCredentials: + """Myriad: the wallet that signs orders, and how requests prove whose they are. + + Every order and every cancel is an EIP-712 message signed by the wallet. + Requests to the account's endpoints are signed with an API key and its + secret (HMAC); without a key, the wallet signs in (SIWE) for a session + token instead. The venue refuses a bare key. A key acts for the wallet + that minted it, so a key with no private key reads the account but + cannot place or cancel.""" + + private_key: str | None = field(default=None, repr=False) + """The wallet's private key, hex: the wallet the Myriad account is + connected to. Signs orders, cancels and the sign-in.""" + api_key: str | None = None + api_secret: str | None = field(default=None, repr=False) + """An API key and its secret (Myriad Account settings, or `POST + /auth/api-keys` from a signed-in session; the secret is shown once).""" + account_address: str | None = None + """The wallet's address, when no private key names it. Public.""" + rpc_url: str | None = None + """A BNB Chain node for the balance read. A public one when unset.""" + _public = ("api_key", "account_address", "rpc_url") + __repr__ = _redacted_repr + + @property + def address(self) -> str | None: + """The trading wallet: the signer's address, else the one given.""" + if self.private_key: + from .polymarket_signing import WalletSigner + + return WalletSigner(self.private_key).address + return self.account_address + + @property + def secrets(self) -> list[str]: + return [v for v in (self.private_key, self.api_secret) if v] + + @dataclass(frozen=True, repr=False) class BetfairCredentials: """Betfair: the account's application key and its login. Every Betfair @@ -360,7 +404,7 @@ def secrets(self) -> list[str]: Credentials = ( KalshiCredentials | PolymarketCredentials | PolymarketUSCredentials | PolymarketUSExchangeCredentials | OpinionCredentials | HyperliquidCredentials | PredictFunCredentials | LimitlessCredentials | SxBetCredentials - | BetfairCredentials | SmarketsCredentials + | MyriadCredentials | BetfairCredentials | SmarketsCredentials ) ENV_NAMES: dict[str, tuple[str, ...]] = { @@ -384,6 +428,7 @@ def secrets(self) -> list[str]: ), "limitless": ("LIMITLESS_PRIVATE_KEY", "LIMITLESS_API_TOKEN_ID", "LIMITLESS_API_SECRET", "LIMITLESS_RPC_URL"), "sx_bet": ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY", "SX_BET_ACCOUNT_ADDRESS", "SX_BET_TESTNET"), + "myriad": ("MYRIAD_PRIVATE_KEY", "MYRIAD_API_KEY", "MYRIAD_API_SECRET", "MYRIAD_ACCOUNT_ADDRESS", "MYRIAD_RPC_URL"), "betfair": ( "BETFAIR_APP_KEY", "BETFAIR_USERNAME", "BETFAIR_PASSWORD", "BETFAIR_CERT_PATH", "BETFAIR_KEY_PATH", "BETFAIR_JURISDICTION", @@ -638,6 +683,35 @@ def load_sx_bet(env: Mapping[str, str]) -> SxBetCredentials | None: return SxBetCredentials(api_key=api_key, private_key=key, account_address=account, testnet=testnet) +def load_myriad(env: Mapping[str, str]) -> MyriadCredentials | None: + key = env.get("MYRIAD_PRIVATE_KEY") or None + api_key, secret = env.get("MYRIAD_API_KEY") or None, env.get("MYRIAD_API_SECRET") or None + if api_key and not secret: + raise CredentialsMissing( + "MYRIAD_API_KEY is set but MYRIAD_API_SECRET is not; the venue refuses a key without its signature " + "since 2026-08-15. A key made before secrets existed cannot be upgraded: mint a new one in Myriad " + "Account settings") + if secret and not api_key: + raise CredentialsMissing("MYRIAD_API_SECRET is set but MYRIAD_API_KEY is not") + if not key and not api_key: + return None + if key and not re.match(r"^(0x)?[0-9a-fA-F]{64}$", key): + raise CredentialsMissing("MYRIAD_PRIVATE_KEY must be 64 hex characters, optionally starting with 0x") + key = None if key is None else key if key.startswith("0x") else "0x" + key + account = env.get("MYRIAD_ACCOUNT_ADDRESS") or None + if account and not re.match(r"^0x[0-9a-fA-F]{40}$", account): + raise CredentialsMissing( + f"MYRIAD_ACCOUNT_ADDRESS must be the wallet's 0x address (40 hex characters), not its private key; " + f"got {_shape_of(account)}") + if account and key: + from .polymarket_signing import WalletSigner + + if WalletSigner(key).address.lower() != account.lower(): + raise CredentialsMissing("MYRIAD_ACCOUNT_ADDRESS is not the address of MYRIAD_PRIVATE_KEY") + return MyriadCredentials(private_key=key, api_key=api_key, api_secret=secret, account_address=account, + rpc_url=env.get("MYRIAD_RPC_URL") or None) + + def _shape_of(value: str) -> str: """What a malformed value looks like, without echoing it: it may be a private key pasted into the wrong variable.""" @@ -695,6 +769,7 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: "predict_fun": load_predict_fun, "limitless": load_limitless, "sx_bet": load_sx_bet, + "myriad": load_myriad, "betfair": load_betfair, "smarkets": load_smarkets, } diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..8e042fa 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -250,6 +250,29 @@ def ask_sx_bet(p: Prompter) -> dict[str, str]: return values +def ask_myriad(p: Prompter) -> dict[str, str]: + p.say("\nMyriad: market data is public and needs nothing; this is for trading on its order book (BNB Chain).") + p.say("The private key (64 hex characters) of the wallet your Myriad account is connected to signs every") + p.say("order and cancel. An API key and secret (Myriad Account settings; the secret is shown once) sign") + p.say("your requests and raise the rate limit; without one, the wallet signs in for a session instead, and a") + p.say("wallet that never signed in gets a Myriad account on its first sign-in. A key alone reads the account.") + api_key = p.value("API key (empty to sign in with the wallet instead)", default="", check=lambda raw: None) + values: dict[str, str] = {} + if api_key: + values["MYRIAD_API_KEY"] = api_key + values["MYRIAD_API_SECRET"] = p.value("API secret (typed hidden)", secret=True, check=_required("The API secret")) + key = _optional_private_key(p, purpose="placing and cancelling orders") + else: + key = p.value("Private key (64 hex characters, typed hidden)", secret=True, check=_private_key) + if key: + values["MYRIAD_PRIVATE_KEY"] = _with_0x(key) + else: + account = p.value("Wallet address, 0x and 40 hex (public, not the private key)", + check=lambda raw: None if ADDRESS.match(raw) else _not_an_address(raw)) + values["MYRIAD_ACCOUNT_ADDRESS"] = account + return values + + def ask_betfair(p: Prompter) -> dict[str, str]: p.say("\nBetfair: your application key (the delayed key is free: developer.betfair.com, or the Accounts API") p.say("visualiser) and your Betfair login. Every Betfair call, market data included, logs in. The key and the") @@ -287,6 +310,7 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("predict.fun", ("PREDICT_FUN_API_KEY", "PREDICT_FUN_PRIVATE_KEY"), ask_predict_fun), ("Limitless", ("LIMITLESS_PRIVATE_KEY",), ask_limitless), ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), + ("Myriad", ("MYRIAD_PRIVATE_KEY", "MYRIAD_API_KEY"), ask_myriad), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), ] diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 509a8fd..fb27428 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -52,6 +52,10 @@ # so contracts take any size to a millionth. "sx_bet": {"default_tick": Decimal("0.00125"), "min_quantity": Decimal("0.000001"), "quantity_step": Decimal("0.000001"), "whole": False, "min_notional": Decimal("1")}, + # Order books only: prices in whole cents; sizes are 18-decimal shares and + # the venue publishes no minimum, so a millionth of a share is the step. + "myriad": {"default_tick": Decimal("0.01"), "min_quantity": Decimal("0.000001"), "quantity_step": Decimal("0.000001"), + "whole": False}, # No order entry yet; written down for when there is. Prices are decimal # odds on Betfair's ladder (1.01 to 1000), so the step in YES price runs # from about 0.0097 near 1.01 to 0.00001 near 1000: the tick here is the diff --git a/synpath/trading/myriad.py b/synpath/trading/myriad.py new file mode 100644 index 0000000..af273e2 --- /dev/null +++ b/synpath/trading/myriad.py @@ -0,0 +1,938 @@ +"""Myriad order entry on its order book, and the venue's order and fill records on the YES leg. + +Built to Myriad's API reference: order hashing and signatures are checked +against an independent EIP-712 encoder, request signing against the +reference's HMAC recipe. + +**Two credentials.** Every order and every cancel is an EIP-712 message the +wallet signs. Requests to the account's endpoints prove whose they are with +an API key and its secret (HMAC over the timestamp, method, path and body), +or, without a key, with a session token the wallet signs in for (SIWE; the +first sign-in creates the Myriad account and ties it to the wallet). The +venue refuses a bare key. Orders must be the connected wallet's own. + +**YES is outcome 0, NO outcome 1.** `buy` buys outcome 0 at the price given; +`sell` buys outcome 1 at `1 - price`; with `reduce_only`, `sell` sells outcome +0 held and `buy` sells outcome 1 held. The matcher fills a YES buy against a +YES sell, or against a NO buy at the complement by minting, so both sides +share one book. Everything the venue reports on outcome 1 comes back on the +YES leg. + +**Orders.** A limit is `GTC`, `GTD` with `expires_at`, post-only (`PO`) on +request; an IOC limit or a market order is fill-and-kill (`FAK`) at the price +given as the worst accepted, `fok` is fill-or-kill. Prices are whole cents; +sizes are shares to 18 decimals. Up to 200 orders a batch. An edit is the +venue's atomic cancel-and-place, so the queue place is lost. + +**Cancels repeat the order.** The venue cancels an order only with the order +and its signature in the body. Orders placed here are remembered; any other +is read back from the venue and signed again (the same order, the same key, +the same signature). + +**Fees** are taker-only: a share of the notional by price, from the market's +table (`bps_by_price`). Makers pay nothing. + +AMM markets trade on-chain, one contract call per trade with gas, through the +venue's quote route; that is not built here, and an order for one is refused. +Approvals are `approval_calls()`, for the wallet to send once; split, merge +and redeem calldata come from `position_calldata()`. +""" +from __future__ import annotations + +import asyncio +import json +import re +import time +from decimal import Decimal +from typing import Any +from urllib.parse import urlencode + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound +from ..myriad import ORDER_BOOK_NETWORK, Ref, iso_ms, native_id, parse_id +from ..types import Page +from . import myriad_signing as sig +from .base import TradingExchange +from .credentials import MyriadCredentials +from .errors import ( + CredentialsMissing, DuplicateClientOrderId, InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected, +) +from .limiter import BudgetLimiter, Priority +from .polymarket import to_yes_leg, wire_leg +from .polymarket_signing import WalletSigner +from .types import ( + Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, + Position, PositionSide, SettlementState, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "myriad" +API_URL = "https://api-v2.myriadprotocol.com" +RPC_URL = "https://bsc-dataseed.bnbchain.org" +SCALE = Decimal(10) ** 18 +BATCH = 200 +"""Orders per batch place, cancel or modify: the venue's maximum.""" + +SESSION_S = 12 * 60 +"""A session token is renewed after this long; the venue's last 15 minutes.""" + +CLIENT_ORDER_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + +STATUS = {"open": OrderStatus.OPEN, "filled": OrderStatus.CLOSED, "cancelled": OrderStatus.CANCELED, + "canceled": OrderStatus.CANCELED, "expired": OrderStatus.EXPIRED} + +TIF = { + "GTC": (OrderType.LIMIT, TimeInForce.GTC), + "GTD": (OrderType.LIMIT, TimeInForce.GTD), + "PO": (OrderType.LIMIT, TimeInForce.GTC), + "FAK": (OrderType.LIMIT, TimeInForce.IOC), + "FOK": (OrderType.LIMIT, TimeInForce.FOK), +} +"""The venue's time in force as this library's order type and time in force.""" + +UPDATE_STATUS = {"PLACED": OrderStatus.OPEN, "PARTIALLY_FILLED": OrderStatus.OPEN, "FILLED": OrderStatus.CLOSED, + "CANCELLED": OrderStatus.CANCELED, "EXPIRED": OrderStatus.EXPIRED} + + +def D(value: Any) -> Decimal: + return Decimal(str(value)) if value not in (None, "") else Decimal("0") + + +def wei(value: Any) -> Decimal: + """An 18-decimal integer as tokens.""" + return D(int(str(value))) / SCALE if value not in (None, "") else Decimal("0") + + +def _outcome(value: Any) -> str: + return "no" if int(value or 0) == 1 else "yes" + + +def _venue_side(value: Any) -> str: + """`0`/`"buy"` -> `BUY`, `1`/`"sell"` -> `SELL`; a mint is a buy, a merge a sell.""" + text = str(value).lower() + return "SELL" if text in ("1", "sell", "merge") else "BUY" + + +def market_id_of(network: Any, market: Any, *, amm: bool = False, outcome: int | None = None) -> str: + return ids.qualify(VENUE, native_id(network or ORDER_BOOK_NETWORK, market, amm=amm, outcome=outcome)) + + +# --------------------------------------------------------------------------- +# Order bodies +# --------------------------------------------------------------------------- + +def immediate(request: OrderRequest) -> bool: + return request.type == OrderType.MARKET or request.time_in_force in (TimeInForce.IOC, TimeInForce.FOK) + + +def time_in_force_of(request: OrderRequest) -> tuple[str, int]: + """`(time_in_force, expiration)`: the venue's word and the signed expiry, unix seconds.""" + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder(f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; " + f"submit it through the engine") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") + tif = request.time_in_force + if tif == TimeInForce.DAY: + raise InvalidOrder(f"{VENUE}: 'day' is rewritten to 'gtd' by the engine; an adapter cannot pick a session end") + expiration = 0 + if tif == TimeInForce.GTD: + if request.expires_at is None: + raise InvalidOrder(f"{VENUE}: a gtd order needs expires_at") + expiration = int(request.expires_at // 1000) + if request.post_only: + if immediate(request): + raise InvalidOrder(f"{VENUE}: a post-only order cannot also take at once") + return "PO", expiration + if request.type == OrderType.MARKET: + return ("FOK" if tif == TimeInForce.FOK else "FAK"), 0 + return {TimeInForce.GTC: "GTC", TimeInForce.GTD: "GTD", TimeInForce.IOC: "FAK", TimeInForce.FOK: "FOK"}[tif], expiration + + +def build_signed_order( + request: OrderRequest, *, signer: WalletSigner, market: int, nonce: int | None = None, +) -> tuple[dict[str, Any], str, Decimal]: + """`(entry, order hash, contracts)`: the signed `{order, signature, + signatureType, time_in_force}` entry `POST /orders` and the batch routes + take, before anything is sent.""" + tif, expiration = time_in_force_of(request) + outcome, venue_side = wire_leg(request) + yes_price = D(request.price) + price = yes_price if outcome == "yes" else Decimal("1") - yes_price + amount = sig.shares_wei(D(request.quantity)) + order = sig.build_order( + trader=signer.address, market_id=market, outcome=sig.YES if outcome == "yes" else sig.NO, + side=sig.BUY if venue_side == "BUY" else sig.SELL, amount=amount, price=sig.price_wei(price), + nonce=nonce if nonce is not None else sig.new_nonce(), expiration=expiration, + ) + signature, digest = sig.sign_order(signer, order) + entry = {"order": sig.wire_order(order), "signature": signature, "signatureType": sig.EOA, "time_in_force": tif} + return entry, digest, Decimal(amount) / SCALE + + +# --------------------------------------------------------------------------- +# REST records +# --------------------------------------------------------------------------- + +def order_of_record(row: dict[str, Any], *, account: Account | None = None) -> Order: + """An order record (`GET /orders`, `GET /orders/:hash`) on the YES leg.""" + struct = row.get("order") or {} + outcome, venue_side = _outcome(struct.get("outcomeId")), _venue_side(struct.get("side")) + side, price = to_yes_leg(outcome, venue_side, wei(struct.get("price")) if struct.get("price") not in (None, "") else None) + quantity, filled = wei(struct.get("amount")), wei(row.get("filledAmount")) + status = STATUS.get(str(row.get("status") or "").lower(), OrderStatus.OPEN) + kind = str(row.get("timeInForce") or "GTC").upper() + order_type, tif = TIF.get(kind, (OrderType.LIMIT, TimeInForce.GTC)) + expiration = int(str(struct.get("expiration") or 0)) + return Order( + id=str(row.get("orderHash") or ""), + client_order_id=row.get("clientOrderId") or None, + venue=VENUE, + account=account, + market_id=market_id_of(row.get("networkId"), struct.get("marketId")), + side=side, + type=order_type, + time_in_force=tif, + status=status, + price=price, + quantity=quantity, + filled=filled, + remaining=max(Decimal("0"), quantity - filled) if status == OrderStatus.OPEN else Decimal("0"), + post_only=kind == "PO", + reduce_only=venue_side == "SELL", + expires_at=expiration * 1000 if expiration else None, + created_at=iso_ms(row.get("createdAt")), + updated_at=iso_ms(row.get("updatedAt")), + info={**row, "outcome": outcome, "venue_side": venue_side}, + ) + + +def order_of_update(data: dict[str, Any], *, account: Account | None = None) -> Order: + """An `orders:` channel update (placed, partly filled, filled, cancelled, + expired) as the order it describes, on the YES leg.""" + outcome, venue_side = _outcome(data.get("outcome")), _venue_side(data.get("side")) + side, price = to_yes_leg(outcome, venue_side, wei(data.get("price")) if data.get("price") not in (None, "") else None) + quantity, filled = wei(data.get("amount")), wei(data.get("filledAmount")) + status = STATUS.get(str(data.get("status") or "").lower()) or UPDATE_STATUS.get(str(data.get("type") or ""), + OrderStatus.OPEN) + kind = str(data.get("timeInForce") or "GTC").upper() + order_type, tif = TIF.get(kind, (OrderType.LIMIT, TimeInForce.GTC)) + return Order( + id=str(data.get("orderHash") or ""), + venue=VENUE, + account=account, + market_id=market_id_of(data.get("networkId"), data.get("marketId")), + side=side, + type=order_type, + time_in_force=tif, + status=status, + price=price, + quantity=quantity, + filled=filled, + remaining=max(Decimal("0"), quantity - filled) if status == OrderStatus.OPEN else Decimal("0"), + post_only=kind == "PO", + reduce_only=venue_side == "SELL", + created_at=iso_ms(data.get("createdAt")), + updated_at=iso_ms(data.get("updatedAt")), + info={**data, "outcome": outcome, "venue_side": venue_side}, + ) + + +def fill_of_action(row: dict[str, Any], *, account: Account | None = None) -> Fill | None: + """One trade in the account's history (`/users/:address/events`) as a fill + on the YES leg, or `None` for anything else (claims, liquidity). The + history names neither the order nor the fee.""" + action = str(row.get("action") or "") + if action not in ("buy", "sell"): + return None + shares, value = D(row.get("shares")), D(row.get("value")) + if shares <= 0: + return None + outcome = int(row.get("outcomeId") or 0) + side, price = to_yes_leg(_outcome(outcome), action.upper(), value / shares) + stamp = iso_ms(row.get("timestamp")) or 0 + return Fill( + id=f"{row.get('txId') or stamp}:{outcome}:{action}", + order_id="", + venue=VENUE, + account=account, + market_id=market_id_of(row.get("networkId"), row.get("marketId")), + side=side, + price=price or Decimal("0"), + quantity=shares, + fee=None, + fee_currency=None, + liquidity=Liquidity.UNKNOWN, + settlement=SettlementState.CONFIRMED, + timestamp=stamp, + info={k: v for k, v in row.items() if k not in ("imageUrl", "marketTitle")}, + ) + + +def fills_of_trade(data: dict[str, Any], *, address: str, account: Account | None = None) -> list[Fill]: + """This account's side of a `trades:` match: one fill per outcome and + side it traded in the transaction, summed over its legs. The id is the + transaction's, the outcome and the side, the same key the account's + history gives the trade.""" + mine = address.lower() + legs: list[tuple[dict[str, Any], Liquidity, Decimal, Decimal, Decimal]] = [] + taker = data.get("taker") or {} + if str(taker.get("trader") or "").lower() == mine: + legs.append((taker, Liquidity.TAKER, wei(taker.get("totalAmount")), wei(taker.get("averagePrice")), + wei((taker.get("totalFees") or {}).get("total")))) + for maker in data.get("makers") or []: + if str(maker.get("trader") or "").lower() == mine: + legs.append((maker, Liquidity.MAKER, wei(maker.get("amount")), wei(maker.get("price")), + wei((maker.get("fees") or {}).get("total")))) + groups: dict[tuple[int, str], list[tuple[dict[str, Any], Liquidity, Decimal, Decimal, Decimal]]] = {} + for leg in legs: + groups.setdefault((int(leg[0].get("outcome") or 0), _venue_side(leg[0].get("side")).lower()), []).append(leg) + fills = [] + for (outcome, action), group in groups.items(): + quantity = sum((leg[2] for leg in group), Decimal("0")) + if quantity <= 0: + continue + average = sum((leg[2] * leg[3] for leg in group), Decimal("0")) / quantity + side, price = to_yes_leg(_outcome(outcome), action.upper(), average) + liquidity = {leg[1] for leg in group} + fills.append(Fill( + id=f"{data.get('txHash')}:{outcome}:{action}", + order_id=str(group[0][0].get("orderHash") or ""), + venue=VENUE, + account=account, + market_id=market_id_of(data.get("networkId"), data.get("marketId")), + side=side, + price=price or Decimal("0"), + quantity=quantity, + fee=sum((leg[4] for leg in group), Decimal("0")), + fee_currency="USD1", + liquidity=liquidity.pop() if len(liquidity) == 1 else Liquidity.UNKNOWN, + settlement=SettlementState.CONFIRMED, + timestamp=int(data.get("ts") or 0), + info={"tx_hash": data.get("txHash"), "orders": [str(leg[0].get("orderHash")) for leg in group]}, + )) + return fills + + +RESOLVED = ("won", "lost", "claimed", "voided") + + +def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: + """The account's portfolio rows (one per market and outcome) as positions: + a binary market's two outcomes netted on the YES leg with both kept, and + an outcome of a many-outcome AMM market as its own market.""" + by_market: dict[tuple[int, int, bool], list[dict[str, Any]]] = {} + for row in rows: + if row.get("marketId") is None: + continue + key = (int(row.get("networkId") or ORDER_BOOK_NETWORK), int(row["marketId"]), row.get("executionMode") == 0) + by_market.setdefault(key, []).append(row) + out: list[Position] = [] + for (network, market, amm), group in by_market.items(): + many = amm and any(int(r.get("outcomeId") or 0) > 1 for r in group) + if many: + for row in group: + out.append(_position(network, market, amm, {int(row.get("outcomeId") or 0): row}, account=account, + outcome=int(row.get("outcomeId") or 0))) + else: + out.append(_position(network, market, amm, {int(r.get("outcomeId") or 0): r for r in group}, account=account)) + return out + + +def _position(network: int, market: int, amm: bool, legs: dict[int, dict[str, Any]], *, + account: Account | None, outcome: int | None = None) -> Position: + yes_row = legs.get(outcome if outcome is not None else 0) or {} + no_row = {} if outcome is not None else (legs.get(1) or {}) + held_yes, held_no = D(yes_row.get("shares")), D(no_row.get("shares")) + net = held_yes - held_no + lead, lead_row = ("yes", yes_row) if net >= 0 else ("no", no_row) + entry = D(lead_row.get("price")) if lead_row.get("price") not in (None, "") else None + if entry is not None and lead == "no": + entry = Decimal("1") - entry + mark: Decimal | None = None + if yes_row.get("value") not in (None, "") and held_yes > 0: + mark = D(yes_row["value"]) / held_yes + elif no_row.get("value") not in (None, "") and held_no > 0: + mark = Decimal("1") - D(no_row["value"]) / held_no + profits = [r.get("profit") for r in (yes_row, no_row) if r.get("profit") not in (None, "")] + statuses = {str(r.get("status") or "") for r in (yes_row, no_row) if r} + resolved = bool(statuses) and statuses <= set(RESOLVED) + won = any(s in ("won", "claimed") for s in statuses) if resolved else None + to_claim = [r for r in (yes_row, no_row) if r.get("winningsToClaim") or r.get("voidedWinningsToClaim")] + return Position( + venue=VENUE, + account=account, + market_id=market_id_of(network, market, amm=amm, outcome=outcome), + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), + inventory_yes=held_yes, + inventory_no=held_no if outcome is None else None, + entry_price=entry, + mark_price=mark, + unrealized_pnl=sum((D(p) for p in profits), Decimal("0")) if profits else None, + resolved=resolved, + final=resolved, + won=won, + redeemable=sum((D(r.get("shares")) for r in to_claim), Decimal("0")) if to_claim else None, + info={"rows": [r for r in (yes_row, no_row) if r]}, + ) + + +def error_of(exc: ExchangeError, *, client_order_id: str | None = None) -> ExchangeError: + """The venue's refusal of an order as a typed error, by its words.""" + body = exc.body if isinstance(exc.body, dict) else {} + text = str(body.get("error") or body.get("message") or exc) + code = str(body.get("code") or "") + lower = f"{text} {code}".lower() + if code == "NO_WALLET_CONNECTED" or "wallet_mismatch" in lower or "trader_forbidden" in lower: + return CredentialsMissing(f"{VENUE}: {text} -- orders must be the wallet the account signed in with") + if exc.status == 409: + if client_order_id and "client" in lower: + return DuplicateClientOrderId(f"{VENUE}: {text}") + return OrderRejected(f"{VENUE}: {text}", reason="duplicate", body=exc.body, status=exc.status) + if "balance" in lower or "allowance" in lower or "insufficient" in lower: + return InsufficientFunds(f"{VENUE}: {text}", body=exc.body) + if any(word in lower for word in ("price", "amount", "tick", "invalid", "expiration", "payload")): + return InvalidOrder(f"{VENUE}: {text}") + return OrderRejected(f"{VENUE}: {text}", reason=code or None, info=body, body=exc.body, status=exc.status) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class MyriadTrading(TradingExchange): + """Myriad order entry, on the order book. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.myriad import MyriadTrading + + async with MyriadTrading(require("myriad", load_credentials())) as myriad: + print(await myriad.fetch_balance()) + ``` + """ + + id = VENUE + name = "Myriad" + has: dict[str, Capability] = { + "create_order": True, + # Up to 200 a request; the venue checks collateral at match time. + "create_orders": True, + "cancel_order": True, + # Up to 200 a request. + "cancel_orders": True, + "cancel_all_orders": True, + # The venue's atomic cancel-and-place: the queue place is lost. + "edit_order": True, + "fetch_order": True, + "fetch_open_orders": True, + "fetch_orders": True, + # The account's trade history names neither the order nor the fee. + "fetch_my_trades": "partial", + "fetch_positions": True, + "fetch_balance": True, + "fetch_settlements": False, + "fetch_queue_position": False, + "fetch_fee_estimate": True, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: MyriadCredentials, + *, + account_name: str = "default", + url: str = API_URL, + rpc_url: str | None = None, + catalog: Any = None, + limiter: BudgetLimiter | None = None, + timeout: float = 30.0, + client: Any = None, + rpc_client: Any = None, + ): + import httpx + + self.credentials = credentials + self.signer = WalletSigner(credentials.private_key) if credentials.private_key else None + self.address = credentials.address + """The trading wallet: the signer's, or `MYRIAD_ACCOUNT_ADDRESS`.""" + self.account = Account(venue=VENUE, name=account_name) + keyed = bool(credentials.api_key and credentials.api_secret) + # 200 requests a second with a key, 30 per 10 seconds without; orders + # 200 per 10 seconds per wallet either way. + self.limiter = limiter or BudgetLimiter(read_per_second=50 if keyed else 2.5, + write_per_second=15 if keyed else 2.5) + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + self.http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE) + self.rpc_url = rpc_url or credentials.rpc_url or RPC_URL + self.rpc = rpc_client or httpx.AsyncClient(timeout=20, follow_redirects=True) + self._own_catalog = catalog is None + if catalog is None: + from ..myriad import KEYED_LIMITER, LIMITER, Myriad + + catalog = Myriad(api_key=credentials.api_key, api_secret=credentials.api_secret, + limiter=KEYED_LIMITER if keyed else LIMITER) + self.catalog = catalog + """The read adapter: market ids, fee tables.""" + self._signed: dict[str, dict[str, Any]] = {} + """Order hash -> the signed `{order, signature}` a cancel repeats.""" + self._session: dict[str, Any] | None = None + + @property + def keyed(self) -> bool: + return bool(self.credentials.api_key and self.credentials.api_secret) + + # -- plumbing ------------------------------------------------------------- + + def _need_signer(self, what: str) -> WalletSigner: + if self.signer is None: + raise CredentialsMissing(f"{VENUE}: {what} needs the wallet's signature; set MYRIAD_PRIVATE_KEY") + return self.signer + + def _need_address(self) -> str: + if not self.address: + raise CredentialsMissing(f"{VENUE}: the account's wallet is unknown; set MYRIAD_PRIVATE_KEY or " + f"MYRIAD_ACCOUNT_ADDRESS") + return self.address + + async def _call( + self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, signed: bool = True, retry: bool = True, + ) -> Any: + """One call, paced and proven: HMAC over exactly what is sent with a + key, the session token without one. The query string and the body + are built here exactly as sent.""" + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + query = urlencode([(k, v) for k, v in (params or {}).items() if v is not None], doseq=True) + target = f"{path}?{query}" if query else path + text = json.dumps(body, separators=(",", ":"), ensure_ascii=False) if body is not None else "" + headers: dict[str, str] = {"Content-Type": "application/json"} if text else {} + if signed: + if self.keyed: + headers.update(sig.request_headers(self.credentials.api_key or "", self.credentials.api_secret or "", + method, target, text)) + else: + headers["Authorization"] = f"Bearer {await self._access_token()}" + try: + return await self.http.request(method, target, content=text.encode() if text else None, headers=headers) + except AuthenticationError as exc: + if signed and retry and not self.keyed and exc.status == 401: + self._session = None + return await self._call(method, path, params=params, body=body, kind=kind, priority=priority, + retry=False) + raise + except MarketNotFound as exc: + if path.startswith("/orders"): + raise OrderNotFound(str(exc), body=exc.body, status=exc.status) from None + raise + + async def _access_token(self) -> str: + """The session token: kept, refreshed once it ages, else a fresh sign-in.""" + session = self._session + if session and time.time() - session["at"] < SESSION_S: + return str(session["access"]) + if session and session.get("refresh"): + try: + raw = await self._call("POST", "/auth/refresh", body={"refreshToken": session["refresh"]}, + signed=False) or {} + if raw.get("accessToken"): + self._keep_session(raw) + return str(raw["accessToken"]) + except (AuthenticationError, BadRequest): + pass + return await self.login() + + def _keep_session(self, raw: dict[str, Any]) -> None: + refresh = raw.get("refreshToken") or (self._session or {}).get("refresh") + self._session = {"access": raw["accessToken"], "refresh": refresh, "at": time.time()} + + async def login(self) -> str: + """Sign in with the wallet (SIWE) and keep the session token. The + venue's first sign-in for a wallet creates its Myriad account.""" + signer = self._need_signer("signing in") + nonce = await self._call("GET", "/auth/nonce", params={"address": signer.address}, signed=False) or {} + message = nonce.get("message") + if not message: + raise AuthenticationError(f"{VENUE}: no sign-in message in the venue's answer") + raw = await self._call("POST", "/auth/login", body={"message": message, "signature": sig.sign_login(signer, message)}, + signed=False) or {} + if not raw.get("accessToken"): + raise AuthenticationError(f"{VENUE}: the sign-in returned no session token") + self._keep_session(raw) + return str(raw["accessToken"]) + + async def create_api_key(self, label: str = "synpath") -> dict[str, Any]: + """Mint an API key for the signed-in wallet: `{key, secret, ...}`, the + secret shown this once. Set them as MYRIAD_API_KEY / MYRIAD_API_SECRET.""" + if self.keyed: + raise BadRequest(f"{VENUE}: an API key is minted from a wallet session; this adapter signs with a key already") + return await self._call("POST", "/auth/api-keys", body={"label": label}, kind="write") or {} + + async def subscription_token(self, channel: str) -> str: + """A short-lived token for one of this wallet's own realtime channels + (`orders:56:0x...`, `positions:56:0x...`).""" + raw = await self._call("POST", "/auth/centrifugo/subscription-token", body={"channel": channel}) or {} + if not raw.get("token"): + raise AuthenticationError(f"{VENUE}: no subscription token for {channel}") + return str(raw["token"]) + + async def _ob_ref(self, market_id: str) -> Ref: + """An order-book market's parts; an AMM market is refused.""" + native = ids.native(VENUE, market_id) + ref = parse_id(native) + if ref is None: + ref, _ = await asyncio.to_thread(self.catalog._resolve, native) + if ref.amm: + raise InvalidOrder(f"{VENUE}: {ref.native} is an AMM market, traded on-chain through the venue's quote " + f"route; this adapter places order-book orders only") + if ref.network != ORDER_BOOK_NETWORK: + raise InvalidOrder(f"{VENUE}: order books run on BNB Chain (56) only, not {ref.network}") + return ref + + def _remember(self, digest: str, entry: dict[str, Any]) -> None: + self._signed[digest.lower()] = {"order": entry["order"], "signature": entry["signature"], + "signatureType": entry.get("signatureType", sig.EOA)} + + async def _signed_entry(self, order_id: str) -> dict[str, Any]: + """The `{order, signature}` a cancel repeats: remembered, or read back + and signed again.""" + cached = self._signed.get(order_id.lower()) + if cached is not None: + return cached + signer = self._need_signer("cancelling an order") + row = await self._call("GET", f"/orders/{order_id}") or {} + struct = row.get("order") or {} + if not struct: + raise OrderNotFound(f"{VENUE}: no order {order_id}") + order = sig.order_of_wire(struct) + if order["trader"].lower() != signer.address.lower(): + raise CredentialsMissing(f"{VENUE}: order {order_id} belongs to {order['trader']}, not this wallet") + signature, digest = sig.sign_order(signer, order) + entry = {"order": sig.wire_order(order), "signature": signature, "signatureType": sig.EOA} + self._signed[digest.lower()] = entry + return entry + + # -- orders --------------------------------------------------------------- + + def _order(self, request: OrderRequest, *, ref: Ref, order_id: str, contracts: Decimal, status: str | None, + info: dict[str, Any]) -> Order: + return Order( + id=order_id, + client_order_id=request.client_order_id, + venue=VENUE, + account=request.account or self.account, + market_id=market_id_of(ref.network, ref.market), + side=request.side, + type=request.type, + time_in_force=request.time_in_force, + status=STATUS.get(str(status or "open").lower(), OrderStatus.OPEN), + price=D(request.price), + quantity=contracts, + filled=Decimal("0"), + reduce_only=request.reduce_only, + post_only=request.post_only, + expires_at=request.expires_at, + created_at=int(time.time() * 1000), + book=request.book, + trader=request.trader, + tags=request.tags, + info=info, + ) + + async def create_order(self, request: OrderRequest) -> Order: + """Sign and place one order. The venue answers once the order is on + the book; fills follow from the matcher.""" + time_in_force_of(request) + signer = self._need_signer("placing an order") + ref = await self._ob_ref(request.market_id) + entry, digest, contracts = build_signed_order(request, signer=signer, market=ref.market) + body: dict[str, Any] = {**entry, "network_id": ref.network} + if request.client_order_id and self.keyed and CLIENT_ORDER_ID.match(request.client_order_id): + body["client_order_id"] = request.client_order_id + try: + raw = await self._call("POST", "/orders", body=body, kind="write") or {} + except (BadRequest, OrderNotFound) as exc: + raise error_of(exc, client_order_id=body.get("client_order_id")) from None + except ExchangeError as exc: + if exc.status == 409: + raise error_of(exc, client_order_id=body.get("client_order_id")) from None + raise + order_id = str(raw.get("orderHash") or digest) + self._remember(order_id, entry) + return self._order(request, ref=ref, order_id=order_id, contracts=contracts, status=raw.get("status"), info={ + "venue": raw, "request": {**body, "signature": None}, "hash": digest, + }) + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many orders, 200 a request, each signed; one result per request in + the order given, a refused one as its exception.""" + results: list[Order | Exception | None] = [None] * len(requests) + prepared: list[tuple[int, OrderRequest, dict[str, Any], str, Decimal, Ref]] = [] + for i, request in enumerate(requests): + try: + time_in_force_of(request) + signer = self._need_signer("placing an order") + ref = await self._ob_ref(request.market_id) + entry, digest, contracts = build_signed_order(request, signer=signer, market=ref.market) + prepared.append((i, request, entry, digest, contracts, ref)) + except Exception as exc: + results[i] = exc + for start in range(0, len(prepared), BATCH): + chunk = prepared[start:start + BATCH] + body = {"orders": [p[2] for p in chunk], "network_id": ORDER_BOOK_NETWORK, "allow_partial": True} + try: + raw = await self._call("POST", "/orders/batch", body=body, kind="write") or {} + except (BadRequest, ExchangeError) as exc: + for i, *_ in chunk: + results[i] = error_of(exc) + continue + placed = {str(h).lower() for h in raw.get("placed") or []} + errors = {int(e.get("index")): e for e in raw.get("errors") or [] if isinstance(e, dict) and e.get("index") is not None} + for position, (i, request, entry, digest, contracts, ref) in enumerate(chunk): + if position in errors or digest.lower() not in placed: + reason = str((errors.get(position) or {}).get("reason") or "not placed") + results[i] = error_of(OrderRejected(reason, body={"error": reason})) + continue + self._remember(digest, entry) + results[i] = self._order(request, ref=ref, order_id=digest, contracts=contracts, status="open", info={ + "request": {**entry, "signature": None}, "hash": digest, + }) + return results # type: ignore[return-value] + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one order (repeating it and its signature) and read it back.""" + entry = await self._signed_entry(order_id) + await self._call("DELETE", f"/orders/{order_id}", body={**entry, "network_id": ORDER_BOOK_NETWORK}, + kind="write", priority=Priority.HIGH) + return await self.fetch_order(order_id) + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel up to 200 orders a request, then read each back.""" + failed: dict[str, str] = {} + entries: list[tuple[str, dict[str, Any]]] = [] + out: dict[str, Exception] = {} + for order_id in order_ids: + try: + entries.append((order_id, await self._signed_entry(order_id))) + except Exception as exc: + out[order_id] = exc + for start in range(0, len(entries), BATCH): + chunk = entries[start:start + BATCH] + raw = await self._call("POST", "/orders/cancel-batch", body={ + "orders": [{k: v for k, v in e.items()} for _, e in chunk], "network_id": ORDER_BOOK_NETWORK, + "allow_partial": True, + }, kind="write", priority=Priority.HIGH) or {} + for item in raw.get("errors") or []: + if isinstance(item, dict) and item.get("index") is not None and int(item["index"]) < len(chunk): + failed[chunk[int(item["index"])][0]] = str(item.get("reason") or "refused") + results: list[Order | Exception] = [] + for order_id in order_ids: + if order_id in out: + results.append(out[order_id]) + continue + try: + order = await self.fetch_order(order_id) + except OrderNotFound as exc: + results.append(exc) + continue + if order_id in failed and order.status == OrderStatus.OPEN: + results.append(OrderRejected(f"{VENUE}: cancel of {order_id} refused: {failed[order_id]}", + reason="cancel_refused")) + continue + results.append(order) + return results + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int: + """Every open order, or every one on a market, with one signed + `CancelAll`. Returns how many the venue cancelled.""" + signer = self._need_signer("cancelling orders") + market = (await self._ob_ref(market_id)).market if market_id else 0 + raw = await self._call("POST", "/orders/cancel-all", body=sig.sign_cancel_all(signer, market), + kind="write", priority=Priority.HIGH) or {} + return int(raw.get("cancelled_count") or 0) + + async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order: + """Replace a resting order with the venue's atomic cancel-and-place: + the old order is cancelled and the new one placed in one request, so + the matcher never sees both. The queue place is lost.""" + current = current or await self.fetch_order(request.order_id) + if current.status != OrderStatus.OPEN: + raise InvalidOrder(f"{VENUE}: order {current.id} is {current.status.value}; only an open order can be edited") + tif = request.time_in_force or current.time_in_force + replacement = OrderRequest( + market_id=current.market_id, side=current.side, quantity=request.quantity or current.remaining or current.quantity, + price=request.price if request.price is not None else current.price, time_in_force=tif, + expires_at=request.expires_at or current.expires_at, post_only=current.post_only, + reduce_only=current.reduce_only, client_order_id=request.client_order_id, + ) + signer = self._need_signer("editing an order") + ref = await self._ob_ref(current.market_id) + cancel = await self._signed_entry(current.id) + entry, digest, contracts = build_signed_order(replacement, signer=signer, market=ref.market) + try: + raw = await self._call("POST", "/orders/batch-modify", body={ + "cancel": [cancel], "place": [entry], "network_id": ref.network, "allow_partial": False, + }, kind="write", priority=Priority.HIGH) or {} + except (BadRequest, ExchangeError) as exc: + raise error_of(exc) from None + placed = [str(h) for h in raw.get("placed") or []] + order_id = placed[0] if placed else digest + self._remember(order_id, entry) + order = self._order(replacement, ref=ref, order_id=order_id, contracts=contracts, status="open", info={ + "venue": raw, "replaces": current.id, "request": {**entry, "signature": None}, "hash": digest, + }) + return order.model_copy(update={"queue_priority_preserved": False}) + + async def fetch_order(self, order_id: str) -> Order: + row = await self._call("GET", f"/orders/{order_id}") or {} + if not row.get("orderHash"): + raise OrderNotFound(f"{VENUE}: no order {order_id}") + return order_of_record(row, account=self.account) + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """This account's orders, newest first. `status` is `open`, `filled`, + `cancelled` or `expired` (also `closed`, `canceled`).""" + word = {"closed": "filled", "canceled": "cancelled"}.get(status or "", status) + if word and word not in ("open", "filled", "cancelled", "expired"): + raise BadRequest(f"{VENUE}: status is open, filled, cancelled or expired, not {status!r}") + market = (await self._ob_ref(market_id)).market if market_id else None + page = int(cursor) if cursor and cursor.isdigit() else 1 + raw = await self._call("GET", "/orders", params={ + "status": word, "network_id": ORDER_BOOK_NETWORK, "market_id": market, "page": page, + "limit": min(limit or 500, 5000), + }) or {} + rows = raw.get("data") or [] if isinstance(raw, dict) else raw or [] + orders = [order_of_record(row, account=self.account) for row in rows] + if since: + orders = [o for o in orders if (o.created_at or since) >= since] + more = bool((raw.get("pagination") or {}).get("hasNext")) if isinstance(raw, dict) else False + return Page(orders, next_cursor=str(page + 1) if more else None) + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + orders: list[Order] = [] + cursor: str | None = None + while True: + page = await self.fetch_orders(status="open", market_id=market_id, limit=5000, cursor=cursor) + orders += page + cursor = page.next_cursor + if not cursor: + return orders + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """One page of this account's order-book trades, newest first, from + its history. The history names neither the order behind a trade nor + its fee, so `order_id` cannot filter it.""" + if order_id: + raise BadRequest(f"{VENUE}: the account's history does not name orders; read the order's filled " + f"amount with fetch_order") + address = self._need_address() + market = (await self._ob_ref(market_id)).market if market_id else None + page = int(cursor) if cursor and cursor.isdigit() else 1 + raw = await self._call("GET", f"/users/{address}/events", params={ + "trading_model": "ob", "network_id": ORDER_BOOK_NETWORK, "market_id": market, + "since": since // 1000 if since else None, "page": page, "limit": min(limit or 100, 100), + }) or {} + rows = raw.get("data") or [] if isinstance(raw, dict) else [] + fills = [f for f in (fill_of_action(row, account=self.account) for row in rows) if f is not None] + if since: + fills = [f for f in fills if f.timestamp >= since] + more = bool((raw.get("pagination") or {}).get("hasNext")) if isinstance(raw, dict) else False + return Page(fills, next_cursor=str(page + 1) if more else None) + + # -- account -------------------------------------------------------------- + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """Outcome shares held, order book and AMM alike, netted per binary + market on the YES leg.""" + address = self._need_address() + rows: list[dict[str, Any]] = [] + page = 1 + while True: + raw = await self._call("GET", f"/users/{address}/portfolio", params={ + "trading_model": "all", "page": page, "limit": 100, + }) or {} + rows += raw.get("data") or [] if isinstance(raw, dict) else [] + if not (isinstance(raw, dict) and (raw.get("pagination") or {}).get("hasNext")) or page >= 50: + break + page += 1 + positions = positions_of(rows, account=self.account) + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + positions = [p for p in positions if p.market_id == wanted] + return [p for p in positions if p.inventory_yes or p.inventory_no] + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """The wallet's USD1 on BNB Chain; what open buy orders reserve is `locked`.""" + address = self._need_address() + data = "0x70a08231" + bytes.fromhex(address[2:]).rjust(32, b"\0").hex() + answer = (await self.rpc.post(self.rpc_url, json={ + "jsonrpc": "2.0", "id": 1, "method": "eth_call", "params": [{"to": sig.USD1, "data": data}, "latest"], + })).json() + if not isinstance(answer, dict) or "result" not in answer: + raise ExchangeError(f"{VENUE}: balance read failed: {answer.get('error') if isinstance(answer, dict) else answer}") + total = Decimal(int(answer["result"], 16)) / SCALE + locked = Decimal("0") + for order in await self.fetch_open_orders(): + venue_side, outcome = order.info.get("venue_side"), order.info.get("outcome") + if venue_side == "BUY" and order.price is not None: + price = order.price if outcome == "yes" else Decimal("1") - order.price + locked += (order.remaining or Decimal("0")) * price + available = max(Decimal("0"), total - locked) + return Balance( + venue=VENUE, account=account or self.account, currency="USD1", + total=total, available=available, locked=locked, buying_power=None, + timestamp=int(time.time() * 1000), info={"address": address, "token": sig.USD1}, + ) + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The taker fee for `quantity` contracts at the YES `price`, on the + outcome actually bought (`1 - price` for a `sell`). Makers pay nothing.""" + schedule = await asyncio.to_thread(self.catalog.fetch_fee_schedule, market_id) + token_price = D(price) if side == Side.BUY else Decimal("1") - D(price) + taker = schedule.estimate(float(token_price), float(quantity), taker=True) + maker = schedule.estimate(float(token_price), float(quantity), taker=False) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, ids.native(VENUE, market_id)), side=side, + price=D(price), quantity=D(quantity), + taker_fee=D(taker) if taker is not None else None, maker_fee=D(maker) if maker is not None else None, + currency="USD1", info={"schedule": schedule.model_dump()}, + ) + + # -- on-chain helpers ----------------------------------------------------- + + def approval_calls(self) -> list[dict[str, str]]: + """The two one-time transactions the wallet sends before trading: USD1 + approved to the exchange (buys) and the outcome shares (sells).""" + return sig.approval_calls() + + async def position_calldata(self, action: str, market_id: str, amount: Decimal | None = None) -> dict[str, Any]: + """The venue's `{to, calldata, value}` for `split` (collateral into + YES and NO), `merge` (back), `redeem` or `redeem-voided` on a market, + for the wallet to send. `amount` is collateral or shares, in tokens.""" + if action not in ("split", "merge", "redeem", "redeem-voided"): + raise BadRequest(f"{VENUE}: action is split, merge, redeem or redeem-voided, not {action!r}") + ref = await self._ob_ref(market_id) + body: dict[str, Any] = {"market_id": ref.market, "network_id": ref.network} + if action in ("split", "merge"): + if amount is None: + raise BadRequest(f"{VENUE}: {action} needs an amount") + body["amount"] = str(sig.to_wei(D(amount))) + return await self._call("POST", f"/positions/{action}", body=body) or {} + + async def close(self) -> None: + await self.http.close() + await self.rpc.aclose() + if self._own_catalog: + self.catalog.close() diff --git a/synpath/trading/myriad_signing.py b/synpath/trading/myriad_signing.py new file mode 100644 index 0000000..153b2d2 --- /dev/null +++ b/synpath/trading/myriad_signing.py @@ -0,0 +1,299 @@ +"""Myriad order signing and request authentication, with no network in sight. + +**Orders.** An order-book order is the venue's own EIP-712 `Order` struct +(trader, marketId, outcomeId, side, amount, price, minFillAmount, nonce, +expiration) under the domain `MyriadCTFExchange`, version `1`, chain 56, with +the order-book exchange as the verifying contract. The digest is the order's +hash, the id the venue's lookups and cancels take. The wallet signs its own +orders (signature type 0); a cancel repeats the order and its signature, and +cancelling everything signs a `CancelAll(trader, marketId, timestamp)`. + +**Amounts.** `amount` is shares in 18-decimal units, `price` a 1e18 fraction +of a token per share on a whole-cent grid (a multiple of 1e16). + +**Requests.** An API key and its secret sign every request: HMAC-SHA256, hex, +of `{unix seconds}.{METHOD}.{path and query}.{body}` under the secret, sent +as `x-api-key`, `x-api-timestamp` and `x-api-signature`. The venue refuses a +bare key. Without a key the wallet signs in (SIWE): the venue's login message, +signed as a personal message, buys a short-lived session token. +""" +from __future__ import annotations + +import hashlib +import hmac +import secrets +import time +from decimal import ROUND_DOWN, Decimal +from typing import Any, Iterator + +import httpx + +from .errors import InvalidOrder +from .polymarket_signing import WalletSigner, _eth, approve_calldata, set_approval_for_all_calldata +from .predict_fun_signing import _domain_separator, _personal_digest + +DOMAIN_NAME = "MyriadCTFExchange" +DOMAIN_VERSION = "1" +CHAIN_ID = 56 +EXCHANGE = "0xa0b6f8ef8EdB64f395018D1933f2273Ce9f0f16A" +CONDITIONAL_TOKENS = "0x6413734f92248D4B29ae35883290BD93212654Dc" +NEG_RISK_ADAPTER = "0xd96F26703Ddbf7d1Cb6858640eca34cF1893d53A" +USD1 = "0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d" +USDT = "0x55d398326f99059fF775485246999027B3197955" + +SCALE = 10**18 +TICK_WEI = 10**16 +"""Prices must be whole cents: a multiple of 1e16.""" + +BUY, SELL = 0, 1 +YES, NO = 0, 1 +EOA = 0 + +ORDER_TYPE = ( + "Order(address trader,uint256 marketId,uint8 outcomeId,uint8 side,uint256 amount,uint256 price," + "uint256 minFillAmount,uint256 nonce,uint256 expiration)" +) +CANCEL_ALL_TYPE = "CancelAll(address trader,uint256 marketId,uint256 timestamp)" + +ORDER_FIELDS = [ + {"name": "trader", "type": "address"}, + {"name": "marketId", "type": "uint256"}, + {"name": "outcomeId", "type": "uint8"}, + {"name": "side", "type": "uint8"}, + {"name": "amount", "type": "uint256"}, + {"name": "price", "type": "uint256"}, + {"name": "minFillAmount", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "expiration", "type": "uint256"}, +] +CANCEL_ALL_FIELDS = [ + {"name": "trader", "type": "address"}, + {"name": "marketId", "type": "uint256"}, + {"name": "timestamp", "type": "uint256"}, +] +DOMAIN_FIELDS = [ + {"name": "name", "type": "string"}, {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}, +] + + +# --------------------------------------------------------------------------- +# Amounts +# --------------------------------------------------------------------------- + +def to_wei(value: Decimal) -> int: + return int((Decimal(str(value)) * SCALE).quantize(Decimal("1"), rounding=ROUND_DOWN)) + + +def from_wei(value: Any) -> Decimal: + return Decimal(int(str(value))) / SCALE + + +def price_wei(price: Decimal) -> int: + """A token price as the venue's 1e18 integer, refused off the cent grid + or outside (0, 1).""" + price = Decimal(str(price)) + if not Decimal("0") < price < Decimal("1"): + raise InvalidOrder(f"myriad: price {price} is outside (0, 1)") + if price != price.quantize(Decimal("0.01")): + raise InvalidOrder(f"myriad: price {price} is not a whole cent; the venue takes prices on a 0.01 grid") + return int(price * 100) * TICK_WEI + + +def shares_wei(shares: Decimal) -> int: + amount = to_wei(shares) + if amount <= 0: + raise InvalidOrder(f"myriad: {shares} shares is not a size") + return amount + + +def new_nonce() -> int: + """A random 256-bit nonce, as the venue's own client makes them.""" + return secrets.randbits(256) + + +# --------------------------------------------------------------------------- +# The order +# --------------------------------------------------------------------------- + +def build_order( + *, trader: str, market_id: int, outcome: int, side: int, amount: int, price: int, + min_fill: int = 0, nonce: int, expiration: int = 0, +) -> dict[str, Any]: + """The order struct's values, before the signature.""" + _, _, _, _, to_checksum_address = _eth() + if outcome not in (YES, NO) or side not in (BUY, SELL): + raise InvalidOrder(f"myriad: outcome {outcome} / side {side}: each is 0 or 1") + return { + "trader": to_checksum_address(trader), + "marketId": int(market_id), + "outcomeId": int(outcome), + "side": int(side), + "amount": int(amount), + "price": int(price), + "minFillAmount": int(min_fill), + "nonce": int(nonce), + "expiration": int(expiration), + } + + +def order_of_wire(wire: dict[str, Any]) -> dict[str, Any]: + """An order as the venue writes it (numbers as strings) as the struct.""" + return build_order( + trader=str(wire["trader"]), market_id=int(str(wire["marketId"])), outcome=int(wire["outcomeId"]), + side=int(wire["side"]), amount=int(str(wire["amount"])), price=int(str(wire["price"])), + min_fill=int(str(wire.get("minFillAmount") or 0)), nonce=int(str(wire["nonce"])), + expiration=int(str(wire.get("expiration") or 0)), + ) + + +def wire_order(order: dict[str, Any]) -> dict[str, Any]: + """The order as `POST /orders` takes it: big integers as decimal strings.""" + return { + "trader": order["trader"], + "marketId": str(order["marketId"]), + "outcomeId": int(order["outcomeId"]), + "side": int(order["side"]), + "amount": str(order["amount"]), + "price": str(order["price"]), + "minFillAmount": str(order["minFillAmount"]), + "nonce": str(order["nonce"]), + "expiration": str(order["expiration"]), + } + + +def _order_struct_hash(order: dict[str, Any]) -> bytes: + abi_encode, _, _, keccak, to_checksum_address = _eth() + return keccak(abi_encode( + ["bytes32", "address", "uint256", "uint8", "uint8", "uint256", "uint256", "uint256", "uint256", "uint256"], + [ + keccak(text=ORDER_TYPE), to_checksum_address(order["trader"]), int(order["marketId"]), + int(order["outcomeId"]), int(order["side"]), int(order["amount"]), int(order["price"]), + int(order["minFillAmount"]), int(order["nonce"]), int(order["expiration"]), + ], + )) + + +def _domain(*, exchange: str, chain_id: int) -> bytes: + return _domain_separator(DOMAIN_NAME, DOMAIN_VERSION, chain_id, exchange) + + +def order_digest(order: dict[str, Any], *, exchange: str = EXCHANGE, chain_id: int = CHAIN_ID) -> bytes: + """The order's EIP-712 digest: its hash on the venue.""" + _, _, _, keccak, _ = _eth() + return keccak(b"\x19\x01" + _domain(exchange=exchange, chain_id=chain_id) + _order_struct_hash(order)) + + +def order_hash(order: dict[str, Any], *, exchange: str = EXCHANGE, chain_id: int = CHAIN_ID) -> str: + return "0x" + order_digest(order, exchange=exchange, chain_id=chain_id).hex() + + +def _typed(primary: str, fields: list[dict[str, str]], message: dict[str, Any], *, exchange: str, + chain_id: int) -> dict[str, Any]: + from eth_utils import to_checksum_address + + return { + "primaryType": primary, + "types": {"EIP712Domain": DOMAIN_FIELDS, primary: fields}, + "domain": {"name": DOMAIN_NAME, "version": DOMAIN_VERSION, "chainId": chain_id, + "verifyingContract": to_checksum_address(exchange)}, + "message": dict(message), + } + + +def order_typed_data(order: dict[str, Any], *, exchange: str = EXCHANGE, chain_id: int = CHAIN_ID) -> dict[str, Any]: + """The same order as EIP-712 typed data, for wallets that sign that.""" + return _typed("Order", ORDER_FIELDS, order, exchange=exchange, chain_id=chain_id) + + +def sign_order(signer: WalletSigner, order: dict[str, Any], *, exchange: str = EXCHANGE, + chain_id: int = CHAIN_ID) -> tuple[str, str]: + """`(signature, hash)` for an order, the signature `0x`-hex.""" + digest = order_digest(order, exchange=exchange, chain_id=chain_id) + return "0x" + bytes(signer.sign_digest(digest)).hex(), "0x" + digest.hex() + + +def cancel_all_digest(trader: str, market_id: int, timestamp: int, *, exchange: str = EXCHANGE, + chain_id: int = CHAIN_ID) -> bytes: + abi_encode, _, _, keccak, to_checksum_address = _eth() + struct = keccak(abi_encode( + ["bytes32", "address", "uint256", "uint256"], + [keccak(text=CANCEL_ALL_TYPE), to_checksum_address(trader), int(market_id), int(timestamp)], + )) + return keccak(b"\x19\x01" + _domain(exchange=exchange, chain_id=chain_id) + struct) + + +def cancel_all_typed_data(trader: str, market_id: int, timestamp: int, *, exchange: str = EXCHANGE, + chain_id: int = CHAIN_ID) -> dict[str, Any]: + from eth_utils import to_checksum_address + + return _typed("CancelAll", CANCEL_ALL_FIELDS, + {"trader": to_checksum_address(trader), "marketId": int(market_id), "timestamp": int(timestamp)}, + exchange=exchange, chain_id=chain_id) + + +def sign_cancel_all(signer: WalletSigner, market_id: int = 0, *, timestamp: int | None = None, + exchange: str = EXCHANGE, chain_id: int = CHAIN_ID) -> dict[str, Any]: + """The `POST /orders/cancel-all` body: every open order, or one market's + (`market_id`; 0 is every market).""" + stamp = int(timestamp if timestamp is not None else time.time()) + digest = cancel_all_digest(signer.address, market_id, stamp, exchange=exchange, chain_id=chain_id) + body: dict[str, Any] = { + "trader": signer.address, "timestamp": str(stamp), + "signature": "0x" + bytes(signer.sign_digest(digest)).hex(), "signatureType": EOA, "network_id": chain_id, + } + if market_id: + body["market_id"] = int(market_id) + return body + + +def sign_login(signer: WalletSigner, message: str) -> str: + """The wallet's personal-message signature over the venue's SIWE message.""" + return "0x" + bytes(signer.sign_digest(_personal_digest(message.encode()))).hex() + + +# --------------------------------------------------------------------------- +# Requests +# --------------------------------------------------------------------------- + +def request_signature(secret: str, timestamp: str, method: str, path: str, body: str = "") -> str: + """HMAC-SHA256, hex, of `timestamp.METHOD.path.body`. `path` includes the query string.""" + message = f"{timestamp}.{method.upper()}.{path}.{body}" + return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest() + + +def request_headers(api_key: str, secret: str, method: str, path: str, body: str = "", *, + now: float | None = None) -> dict[str, str]: + """The three `x-api-*` headers for one request.""" + stamp = str(int(now if now is not None else time.time())) + return {"x-api-key": api_key, "x-api-timestamp": stamp, + "x-api-signature": request_signature(secret, stamp, method, path, body)} + + +class HmacAuth(httpx.Auth): + """Signs every request an httpx client sends with the API key: the path + and query exactly as sent, and the exact body bytes.""" + + def __init__(self, api_key: str, secret: str): + self.api_key = api_key + self._secret = secret + + def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]: + path = request.url.raw_path.decode("ascii") + body = request.content.decode("utf-8") if request.content else "" + request.headers.update(request_headers(self.api_key, self._secret, request.method, path, body)) + yield request + + +# --------------------------------------------------------------------------- +# Approvals +# --------------------------------------------------------------------------- + +def approval_calls(collateral: str = USD1) -> list[dict[str, str]]: + """The two one-time transactions that let the exchange settle this + wallet's orders: the collateral approved to it (buys), and the outcome + shares (sells). `{to, data}` each, for the wallet to send.""" + return [ + {"to": collateral, "data": approve_calldata(EXCHANGE)}, + {"to": CONDITIONAL_TOKENS, "data": set_approval_for_all_calldata(EXCHANGE)}, + ] diff --git a/synpath/types.py b/synpath/types.py index 3233e1d..3d17683 100644 --- a/synpath/types.py +++ b/synpath/types.py @@ -539,6 +539,10 @@ class FeeSchedule(_Base): 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.""" + taker_bps_by_cent: list[float] | None = None + maker_bps_by_cent: list[float] | None = None + """`bps_by_price` (Myriad): the rate at each whole-cent price, in basis + points of the notional, entry 0 at 1c and entry 99 at $1.""" info: dict[str, Any] = Field(default_factory=dict) def estimate(self, price: float, contracts: float, *, taker: bool = True) -> float | None: @@ -597,6 +601,15 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo Expert Fee out. Smarkets charges the same way (2% on its standard tier, no Expert Fee), on net winnings per Smarkets market. + * `bps_by_price` (Myriad order books) — `table[cents - 1] / 10,000 * + P * C`, a share of the notional that depends on the price, read + from the venue's table at the whole cent `P` rounds to + (`taker_bps_by_cent`, `maker_bps_by_cent`): 3 bps at 1c, 180 at 50c. + `taker_rate` and `maker_rate` are the tables' ceilings. Pass the + price of the outcome actually traded. + * `share_of_value` (Myriad AMM markets) — `rate * P * C`, a share of + the value traded, set per market; `taker_rate` is the buy rate + (the sell rate is in `info`). """ rate = self.taker_rate if taker else self.maker_rate if self.fee_type in KALSHI_MAKER_SHARE and self.multiplier is not None: @@ -620,6 +633,14 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo return round(limitless_buy_rate(price) * rate / LIMITLESS_FEE_CEILING * price * contracts, 6) if self.fee_type in ("profit_on_win", "net_market_winnings") and rate is not None: return round(rate * (1 - price) * contracts, 6) + if self.fee_type == "bps_by_price": + table = self.taker_bps_by_cent if taker else self.maker_bps_by_cent + if table: + cents = min(len(table), max(1, round(price * 100))) + return round(table[cents - 1] / 10_000 * price * contracts, 6) + return round(rate * price * contracts, 6) if rate is not None else None + if self.fee_type == "share_of_value" and rate is not None: + return round(rate * price * contracts, 6) if self.fee_type == "quadratic_theta" and rate is not None: power = 1.0 if self.exponent is None else self.exponent return round(rate * contracts * (price * (1 - price)) ** power, 6) diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index 6260aee..be8ed0a 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -50,6 +50,9 @@ def __getattr__(name: str): if name in ("SxBetMarketStream", "SxBetUserStream"): from . import sx_bet return getattr(sx_bet, name) + if name in ("MyriadMarketStream", "MyriadUserStream"): + from . import myriad + return getattr(myriad, name) if name == "BetfairMarketStream": from .betfair import BetfairMarketStream return BetfairMarketStream @@ -75,5 +78,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "MyriadMarketStream", "MyriadUserStream", "BetfairMarketStream", "SmarketsMarketStream", ] diff --git a/synpath/ws/myriad.py b/synpath/ws/myriad.py new file mode 100644 index 0000000..8a36aaf --- /dev/null +++ b/synpath/ws/myriad.py @@ -0,0 +1,563 @@ +"""Myriad's WebSocket: order books, prices, the tape, market changes, and an account's orders. + +One endpoint, `wss://ws.myriadprotocol.com/ws`, speaking Centrifugo's JSON +protocol directly: a `connect` command (anonymous: empty), one `subscribe` +per channel, publications as `push` messages, and the server's empty `{}` +pings answered with `{}`. Several messages can share one frame, a line each. + +**Public channels need nothing.** `orderbook:{network}:{market}`, +`prices:...`, `trades:...` and `markets:{network}` are open to anyone, as is +`trades:{network}:*:{wallet}` (one wallet's matches on every market). The +account's `orders:` and `positions:` channels each need a short-lived +subscription token from the REST API, for the signed-in wallet's own +channels only; the user stream asks the trading adapter for one per channel. + +**Books.** An order-book delta carries the new total at each changed price +level, for both outcomes; `"0"` removes the level. Outcome 0's book is YES and +outcome 1's NO, each already holding what the other side's orders imply. +Both are seeded from the REST snapshot once the subscription is confirmed; +deltas that arrive before it are replayed on top, which is safe because each +one is a level's absolute size. Prices are 1e18 fractions, sizes 18-decimal +shares. Publications are coalesced into windows of about 100 ms. + +Order books only: AMM markets publish nothing here. +""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal +from typing import Any + +from .. import ids +from ..base import Capability +from ..errors import AuthenticationError, BadRequest +from ..trading.types import Side +from .base import ( + BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, + TradeEvent, now_ms, +) + +VENUE = "myriad" +WS_URL = "wss://ws.myriadprotocol.com/ws" +SCALE = Decimal(10) ** 18 +MAX_CHANNELS = 512 + + +def ref_of(market_id: str) -> tuple[int, int]: + """`(network, market)` of an order-book market id.""" + from ..myriad import parse_id + + native = ids.native(VENUE, market_id) + ref = parse_id(native) + if ref is None: + raise BadRequest(f"{VENUE}: streams take a market id (myriad:56-2385), not a slug: {native!r}") + if ref.amm: + raise BadRequest(f"{VENUE}: {native} is an AMM market; the venue streams order books only") + return ref.network, ref.market + + +def market_id_of(network: Any, market: Any) -> str: + return ids.qualify(VENUE, f"{int(network)}-{int(market)}") + + +def _wei(value: Any) -> Decimal | None: + if value in (None, ""): + return None + try: + return Decimal(int(str(value))) / SCALE + except ValueError: + return None + + +def _price(value: Any) -> Decimal | None: + """A 1e18 price, rid of the float artifacts the venue sometimes sends + (`329999999999999936`).""" + price = _wei(value) + return price.quantize(Decimal("0.000001")) if price is not None else None + + +def book_levels(rows: Any) -> list[tuple[Decimal, Decimal]]: + """REST `[[price, amount], ...]` as (price, shares) levels.""" + out = [] + for row in rows or []: + if isinstance(row, (list, tuple)) and len(row) >= 2: + price, size = _price(row[0]), _wei(row[1]) + if price is not None and size is not None and size > 0: + out.append((price, size)) + return out + + +def trade_event(data: dict[str, Any]) -> TradeEvent | None: + """One match from `trades:`, as the taker's print in the YES price.""" + taker = data.get("taker") or {} + price, quantity = _price(taker.get("averagePrice")), _wei(taker.get("totalAmount")) + if price is None or not quantity or data.get("marketId") is None: + return None + side = str(taker.get("side") or "").lower() + buying = side in ("buy", "split") + if int(taker.get("outcome") or 0) == 1: + price, buying = Decimal("1") - price, not buying + return TradeEvent( + venue=VENUE, market_id=market_id_of(data.get("networkId"), data["marketId"]), id=data.get("txHash"), + price=price, quantity=quantity, taker_side=Side.BUY if buying else Side.SELL, + timestamp=int(data["ts"]) if data.get("ts") else None, info=data, + ) + + +def status_event(data: dict[str, Any]) -> MarketStatusEvent | None: + """A `markets:` event (created, resolved) as the market's new state.""" + if data.get("marketId") is None: + return None + kind = str(data.get("type") or "") + state = {"created": "created", "resolved": "settled"}.get(kind, "updated") + return MarketStatusEvent( + venue=VENUE, market_id=market_id_of(data.get("networkId"), data["marketId"]), state=state, # type: ignore[arg-type] + native=kind or None, timestamp=int(data["ts"]) if data.get("ts") else None, info=data, + ) + + +class _CentrifugoStream(Stream): + """Centrifugo's JSON protocol: connect, subscribe (with a per-channel + token where the channel needs one), ping.""" + + venue = VENUE + data_heartbeat = True + + def __init__(self, *, url: str = WS_URL, **kwargs: Any): + super().__init__(url, **kwargs) + self.ready = False + self.channels: set[str] = set() + self._live: set[str] = set() + self._next_id = 0 + self._pending: dict[int, tuple[str, str]] = {} + self._pong = True + + def _command(self, kind: str, body: dict[str, Any], *, channel: str = "") -> dict[str, Any]: + self._next_id += 1 + self._pending[self._next_id] = (kind, channel) + return {"id": self._next_id, kind: body} + + async def on_connect(self) -> None: + self.ready = False + await self.send(self._command("connect", {})) + + def on_disconnect(self) -> None: + self.ready = False + self._live.clear() + self._pending.clear() + + def handle_raw(self, raw: str) -> list[Event] | None: + events: list[Event] = [] + for line in raw.split("\n"): + if not line.strip(): + continue + try: + message = json.loads(line) + except ValueError: + continue + if isinstance(message, dict): + events += self._message(message) + return events + + def _message(self, message: dict[str, Any]) -> list[Event]: + if not message: + if self._pong: + self._later(self.send({})) + return [] + if "id" in message: + 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()) + if kind == "subscribe": + self._live.discard(channel) + return [] + if kind == "connect": + self.ready = True + self._pong = bool((message.get("connect") or {}).get("pong", True)) + self._later(self.resubscribe()) + elif kind == "subscribe": + self.status("subscribed", channel, key=channel) + return self.on_subscribed(channel) + return [] + push = message.get("push") + if isinstance(push, dict): + channel = str(push.get("channel") or "") + publication = push.get("pub") + 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']}") + return [] + + async def channel_token(self, channel: str) -> str | None: + """The subscription token a channel needs, or `None` for a public one.""" + return None + + async def resubscribe(self) -> None: + """Send every channel not yet live on this connection.""" + if not self.ready: + 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") + return + try: + token = await self.channel_token(channel) + except Exception as exc: + self.status("error", f"no subscription token for {channel}: {type(exc).__name__}: {exc}") + continue + self._live.add(channel) + body: dict[str, Any] = {"channel": channel} + if token: + body["token"] = token + await self.send(self._command("subscribe", body, channel=channel)) + + async def add_channels(self, channels: list[str]) -> None: + self.channels.update(channels) + await self.resubscribe() + + async def drop_channels(self, channels: list[str]) -> None: + for channel in channels: + self.channels.discard(channel) + if channel in self._live and self.ready: + self._live.discard(channel) + await self.send(self._command("unsubscribe", {"channel": channel}, channel=channel)) + + def on_subscribed(self, channel: str) -> list[Event]: + return [] + + def on_publication(self, channel: str, data: Any) -> list[Event]: + return [] + + def _later(self, coroutine: Any) -> asyncio.Task | None: + try: + return asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + coroutine.close() + return None + + +class MyriadMarketStream(_CentrifugoStream): + """Books, prices, the tape and market changes for Myriad order-book + markets. Public: no key needed. + + ```python + async with MyriadMarketStream() as stream: + await stream.watch_order_book(["myriad:56-2385"]) + async for event in stream: + ... + ``` + """ + + name = "market" + has: dict[str, Capability] = { + "watch_order_book": True, + "watch_ticker": True, + "watch_trades": True, + # Markets created and resolved, per network. + "watch_market_status": "partial", + } + + def __init__(self, *, catalog: Any = None, **kwargs: Any): + super().__init__(**kwargs) + self.catalog = catalog + """A `Myriad` for seeding books from REST; made when first needed.""" + self.books: dict[tuple[str, int], LocalBook] = {} + """(market id, outcome) -> that outcome's book: 0 is YES, 1 is NO.""" + self._pending_deltas: dict[str, list[list[dict[str, Any]]]] = {} + """Deltas that arrived before a market's snapshot, replayed on it.""" + self.tickers: set[str] = set() + self.trades: set[str] = set() + self.statuses: set[str] | None = None + + # -- intents -------------------------------------------------------------- + + async def watch_order_book(self, market_ids: list[str]) -> None: + """Each market's YES and NO books, seeded from REST and kept by deltas.""" + channels = [] + for market_id in market_ids: + network, market = ref_of(market_id) + key = market_id_of(network, market) + for outcome in (0, 1): + self.books.setdefault((key, outcome), LocalBook()) + channels.append(f"orderbook:{network}:{market}") + await self.add_channels(channels) + + async def watch_ticker(self, market_ids: list[str]) -> None: + """Best bid, ask and last on each outcome, on every change.""" + channels = [] + for market_id in market_ids: + network, market = ref_of(market_id) + self.tickers.add(market_id_of(network, market)) + channels.append(f"prices:{network}:{market}") + await self.add_channels(channels) + + async def watch_trades(self, market_ids: list[str]) -> None: + """Every match on these markets, as the taker's print.""" + channels = [] + for market_id in market_ids: + network, market = ref_of(market_id) + self.trades.add(market_id_of(network, market)) + channels.append(f"trades:{network}:{market}") + await self.add_channels(channels) + + async def watch_market_status(self, market_ids: list[str] | None = None, *, network: int = 56) -> None: + """Markets created and resolved on a network, or only these markets'.""" + if market_ids: + self.statuses = (self.statuses or set()) | {market_id_of(*ref_of(m)) for m in market_ids} + networks = {ref_of(m)[0] for m in market_ids} + else: + self.statuses = set() + networks = {network} + await self.add_channels([f"markets:{n}" for n in sorted(networks)]) + + async def unwatch(self, market_ids: list[str]) -> None: + dropped = [] + for market_id in market_ids: + network, market = ref_of(market_id) + key = market_id_of(network, market) + for outcome in (0, 1): + self.books.pop((key, outcome), None) + self._pending_deltas.pop(key, None) + self.tickers.discard(key) + self.trades.discard(key) + dropped += [f"orderbook:{network}:{market}", f"prices:{network}:{market}", f"trades:{network}:{market}"] + await self.drop_channels(dropped) + + def book(self, market_id: str, side: str = "yes") -> LocalBook | None: + network, market = ref_of(market_id) + return self.books.get((market_id_of(network, market), 1 if side == "no" else 0)) + + # -- messages ------------------------------------------------------------- + + def on_disconnect(self) -> None: + super().on_disconnect() + self._pending_deltas.clear() + for book in self.books.values(): + book.invalidate() + + def on_subscribed(self, channel: str) -> list[Event]: + if channel.startswith("orderbook:"): + _, network, market = channel.split(":") + key = market_id_of(network, market) + self._pending_deltas[key] = [] + self._later(self._seed(key, int(network), int(market))) + return [] + + def _catalog(self) -> Any: + if self.catalog is None: + from ..myriad import Myriad + + self.catalog = Myriad() + return self.catalog + + async def _seed(self, key: str, network: int, market: int) -> None: + """Both outcomes' REST snapshots, with any deltas since replayed on them.""" + try: + catalog = self._catalog() + snapshots = [ + await asyncio.to_thread(catalog._get, f"/markets/{market}/orderbook", + {"network_id": network, "outcome": outcome}) + for outcome in (0, 1) + ] + except Exception as exc: # pragma: no cover - the venue's API down + self.status("error", f"could not seed book {key}: {type(exc).__name__}: {exc}") + return + for event in self.apply_snapshot(key, snapshots): + self.emit(event) + + def apply_snapshot(self, key: str, snapshots: list[Any]) -> list[Event]: + """Seed both books from `[outcome 0, outcome 1]` REST books and replay + the deltas held for them.""" + buffered = self._pending_deltas.pop(key, []) + events: list[Event] = [] + for outcome, payload in enumerate(snapshots): + book = self.books.get((key, outcome)) + if book is None or not isinstance(payload, dict): + continue + recovering = not book.ready and book.timestamp is not None + book.replace(book_levels(payload.get("bids")), book_levels(payload.get("asks"))) + for changes in buffered: + for change in changes: + self._set(book, outcome, change) + book.timestamp = now_ms() + if recovering: + self.status("resynced", f"book {key}", key=key) + bids, asks = book.levels() + events.append(BookEvent( + venue=VENUE, market_id=key, side="yes" if outcome == 0 else "no", kind="snapshot", + bids=bids, asks=asks, best_bid=book.best_bid, best_ask=book.best_ask, + info={"replayed": len(buffered)}, + )) + return events + + @staticmethod + def _set(book: Any, outcome: int, change: dict[str, Any]) -> tuple[Decimal, Decimal, str] | None: + if int(change.get("outcome") or 0) != outcome: + return None + side = "bid" if str(change.get("side")) == "bid" else "ask" + price, size = _price(change.get("price")), _wei(change.get("amount")) + if price is None or size is None: + return None + book.set(side, price, size) + return price, size, side + + def on_publication(self, channel: str, data: Any) -> list[Event]: + if not isinstance(data, dict): + return [] + if channel.startswith("orderbook:"): + return self._on_delta(data) + if channel.startswith("prices:"): + return self._on_prices(data) + if channel.startswith("trades:"): + event = trade_event(data) + return [event] if event and event.market_id in self.trades else [] + if channel.startswith("markets:") and self.statuses is not None: + event = status_event(data) + if event is None or (self.statuses and event.market_id not in self.statuses): + return [] + return [event] + return [] + + def _on_delta(self, data: dict[str, Any]) -> list[Event]: + if data.get("marketId") is None: + return [] + key = market_id_of(data.get("networkId"), data["marketId"]) + changes = [c for c in data.get("changes") or [] if isinstance(c, dict)] + if key in self._pending_deltas: + self._pending_deltas[key].append(changes) + return [] + events: list[Event] = [] + stamp = int(data["ts"]) if data.get("ts") else None + for outcome in (0, 1): + book = self.books.get((key, outcome)) + if book is None or not book.ready: + continue + bids, asks = [], [] + for change in changes: + applied = self._set(book, outcome, change) + if applied is None: + continue + price, size, side = applied + (bids if side == "bid" else asks).append(BookLevel(price, size)) + if not bids and not asks: + continue + book.timestamp = now_ms() + events.append(BookEvent( + venue=VENUE, market_id=key, side="yes" if outcome == 0 else "no", kind="delta", + bids=tuple(sorted(bids, key=lambda level: level.price, reverse=True)), + asks=tuple(sorted(asks, key=lambda level: level.price)), + best_bid=book.best_bid, best_ask=book.best_ask, timestamp=stamp, + )) + return events + + def _on_prices(self, data: dict[str, Any]) -> list[Event]: + if data.get("marketId") is None: + return [] + key = market_id_of(data.get("networkId"), data["marketId"]) + if key not in self.tickers: + return [] + stamp = int(data["ts"]) if data.get("ts") else None + events: list[Event] = [] + for row in data.get("outcomes") or []: + if not isinstance(row, dict): + continue + outcome = int(row.get("outcome") or 0) + events.append(QuoteEvent( + venue=VENUE, market_id=key, side="yes" if outcome == 0 else "no", + bid=_price(row.get("bestBid")), ask=_price(row.get("bestAsk")), last=_price(row.get("last")), + timestamp=stamp, info=row, + )) + return events + + +class MyriadUserStream(_CentrifugoStream): + """An account's orders, fills and share balances on the order book. + + Orders and positions are the wallet's own channels: each subscription + carries a token from `token(channel)` (`MyriadTrading.subscription_token`, + signed with the account's API key or session). Fills come from the public + per-wallet tape, `trades:{network}:*:{wallet}`, with no token. + + A position update names one outcome's new balance; the position emitted + nets it with the other outcome's last balance seen on this stream. + """ + + name = "user" + private = True + has: dict[str, Capability] = { + "watch_orders": True, + "watch_my_trades": True, + # Balances as they change; the other outcome's only once it has changed too. + "watch_positions": "partial", + } + + def __init__(self, *, address: str, token: Any = None, network: int = 56, account_name: str = "default", + **kwargs: Any): + if not address: + raise AuthenticationError(f"{VENUE}: the account stream needs the wallet's address") + super().__init__(**kwargs) + from ..trading.types import Account + + self.address = address.lower() + self.network = int(network) + self._token = token + """A callable (sync or async) taking a channel and returning its subscription token.""" + self.account = Account(venue=VENUE, name=account_name) + self._balances: dict[tuple[str, int], Decimal] = {} + + async def channel_token(self, channel: str) -> str | None: + if not (channel.startswith("orders:") or channel.startswith("positions:")): + return None + if self._token is None: + raise AuthenticationError(f"{VENUE}: {channel} needs a subscription token; pass token= " + f"(MyriadTrading.subscription_token)") + value = self._token(channel) + return await value if asyncio.iscoroutine(value) else value + + async def watch_orders(self, market_ids: list[str] | None = None) -> None: + """Every order on the account; the venue has no per-market channel.""" + await self.add_channels([f"orders:{self.network}:{self.address}"]) + + async def watch_my_trades(self, market_ids: list[str] | None = None) -> None: + """Every match the wallet takes part in, on every market.""" + await self.add_channels([f"trades:{self.network}:*:{self.address}"]) + + async def watch_positions(self, market_ids: list[str] | None = None) -> None: + await self.add_channels([f"positions:{self.network}:{self.address}"]) + + def on_publication(self, channel: str, data: Any) -> list[Event]: + if not isinstance(data, dict): + return [] + from ..trading.myriad import fills_of_trade, order_of_update + + if channel.startswith("orders:"): + order = order_of_update(data, account=self.account) + return [OrderEvent(venue=VENUE, order=order, native=str(data.get("type") or "") or None)] + if channel.startswith("trades:"): + return [FillEvent(venue=VENUE, fill=fill) for fill in fills_of_trade(data, address=self.address, account=self.account)] + if channel.startswith("positions:"): + return self._on_position(data) + return [] + + def _on_position(self, data: dict[str, Any]) -> list[Event]: + from ..trading.types import Position, PositionSide + + if data.get("marketId") is None or data.get("balance") in (None, ""): + return [] + key = market_id_of(data.get("networkId"), data["marketId"]) + outcome = int(data.get("outcome") or 0) + self._balances[(key, outcome)] = _wei(data.get("balance")) or Decimal("0") + held_yes, held_no = self._balances.get((key, 0)), self._balances.get((key, 1)) + net = (held_yes or Decimal("0")) - (held_no or Decimal("0")) + average = _price(data.get("averageEntryPrice")) + return [PositionEvent(venue=VENUE, position=Position( + venue=VENUE, account=self.account, market_id=key, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), inventory_yes=held_yes, inventory_no=held_no, + entry_price=(average if outcome == 0 else Decimal("1") - average) if average is not None and ( + (outcome == 0) == (net >= 0)) else None, + timestamp=int(data["ts"]) if data.get("ts") else None, info=data, + ))] diff --git a/tests/samples/myriad_amm_events.json b/tests/samples/myriad_amm_events.json new file mode 100644 index 0000000..80048e9 --- /dev/null +++ b/tests/samples/myriad_amm_events.json @@ -0,0 +1 @@ +{"data":[{"user":"0x6Ad3ef324183E95855346e693c3CF373b35de785","action":"buy","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$120","outcomeId":0,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":1.47192958,"value":0.9915,"timestamp":1792770965,"blockNumber":129349593,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0xfa406b658b0ff3035e39f2bae835ca9c7cc5742f4829446970234de7f6021342"},{"user":"0x6Ad3ef324183E95855346e693c3CF373b35de785","action":"buy","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$120","outcomeId":0,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":1.47241872,"value":0.9915,"timestamp":1792770965,"blockNumber":129349593,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0xd2347a50e98891dbbf1fe511bbf9887d38b6f1ced2f1460288b572a26f940998"},{"user":"0x6Ad3ef324183E95855346e693c3CF373b35de785","action":"sell","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$120","outcomeId":0,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":3.73348137,"value":2.5147,"timestamp":1792770661,"blockNumber":129348917,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0x77b2f1f6a47b7d88b22a91ed0b329f622592f2a4aa587e6960b7febd7f05b75c"},{"user":"0x0458DcB6A70776f7d13F5E4c170E2ec3c1F53976","action":"buy","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$120","outcomeId":0,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":74.8387174,"value":50,"timestamp":1792770264,"blockNumber":129348038,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0xa9df8b74e510a403c64888435cae98f117d837839487d0109efbf3dcf06ce59e"},{"user":"0x0458DcB6A70776f7d13F5E4c170E2ec3c1F53976","action":"buy","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$120","outcomeId":0,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":76.18121413,"value":50,"timestamp":1792763154,"blockNumber":129332242,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0x1f6f51bdafcd1a990b202663758eb7718338d75cceedf16033dc685d58acfd49"},{"user":"0x490a7847144BCFAFe0040A0bD6fc56c1671C3c4D","action":"sell","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$55","outcomeId":1,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":3.26313074,"value":1.142,"timestamp":1792761324,"blockNumber":129328177,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0xbef205f14586141f420f32e427f0fbb3a47ed96d92ec759ead92c69390ebdc28"},{"user":"0x677A664C0dcb26A11F704A9e08f1a9E7dBE6A742","action":"sell","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$55","outcomeId":1,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":3.28165094,"value":1.1502,"timestamp":1792761310,"blockNumber":129328146,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0x555e40c87cdd110819669b3b9a55625d43a93910dfced93214e2ec37eadbfc4a"},{"user":"0x78927fc8AC549D6DB0e86f7d402C332e6d247481","action":"sell","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$55","outcomeId":1,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":3.12585899,"value":1.0972,"timestamp":1792761300,"blockNumber":129328125,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0xdd4fef96ad0a5db46f32c5304988b26d636f9ada86aeebf42b6d9bacb35c9407"},{"user":"0x81d473a3Fc59a97AA819378aB4849Df907b4F85e","action":"sell","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$55","outcomeId":1,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":3.28656872,"value":1.1553,"timestamp":1792761294,"blockNumber":129328112,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0x16854a30e16e65bfc30346bb6f490547912f35116098b7729c92f3c18483eb13"},{"user":"0x441364E3cc6184b847119354342222E2c4fEcB8d","action":"sell","marketTitle":"Crude Oil’s next move: Pump to $120 or Dump to $55?","marketSlug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","marketId":120473,"networkId":56,"outcomeTitle":"$55","outcomeId":1,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","shares":3.44314606,"value":1.2122,"timestamp":1792761287,"blockNumber":129328095,"token":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","txId":"0x18dfb8d05ca6b81f96ea39b3f036032a42b71dde7cfa54596c58803cdc8b662e"}],"pagination":{"page":1,"limit":10,"total":503903,"totalPages":50391,"hasNext":true,"hasPrev":false}} \ No newline at end of file diff --git a/tests/samples/myriad_book_no.json b/tests/samples/myriad_book_no.json new file mode 100644 index 0000000..7c90d23 --- /dev/null +++ b/tests/samples/myriad_book_no.json @@ -0,0 +1 @@ +{"bids":[["940000000000000000","7985478056844239079"],["930000000000000000","24577572964669734000"],["920000000000000000","18633540372670804000"],["910000000000000000","12558869701726843000"],["900000000000000000","2469135802469135700"],["800000000000000000","3125000000000000000"],["770000000000000000","36411687918368329174"],["760000000000000000","7518796992481199500"],["750000000000000000","7619047619047622000"],["740000000000000000","15444015444015442000"],["700000000000000000","3571428571428571600"],["600000000000000000","4166666666666666800"],["500000000000000000","5000000000000000000"],["400000000000000000","6250000000000000000"],["300000000000000000","8333333333333334000"],["200000000000000000","12500000000000000000"],["100000000000000000","25000000000000000000"]],"asks":[["970000000000000000","111051733333333344000"],["980000000000000000","362937062937062920000"],["990000000000000000","220279720279720270000"]]} \ No newline at end of file diff --git a/tests/samples/myriad_book_yes.json b/tests/samples/myriad_book_yes.json new file mode 100644 index 0000000..e5fde64 --- /dev/null +++ b/tests/samples/myriad_book_yes.json @@ -0,0 +1 @@ +{"bids":[["30000000000000000","111051733333333344000"],["20000000000000000","362937062937062920000"],["10000000000000000","220279720279720270000"]],"asks":[["60000000000000000","7985478056844239079"],["70000000000000000","24577572964669734000"],["80000000000000000","18633540372670804000"],["90000000000000000","12558869701726843000"],["100000000000000000","2469135802469135700"],["200000000000000000","3125000000000000000"],["230000000000000000","36411687918368329174"],["240000000000000000","7518796992481199500"],["250000000000000000","7619047619047622000"],["260000000000000000","15444015444015442000"],["300000000000000000","3571428571428571600"],["400000000000000000","4166666666666666800"],["500000000000000000","5000000000000000000"],["600000000000000000","6250000000000000000"],["700000000000000000","8333333333333334000"],["800000000000000000","12500000000000000000"],["900000000000000000","25000000000000000000"]]} \ No newline at end of file diff --git a/tests/samples/myriad_event.json b/tests/samples/myriad_event.json new file mode 100644 index 0000000..8dfd8e3 --- /dev/null +++ b/tests/samples/myriad_event.json @@ -0,0 +1 @@ +{"type":"event","id":"2257269d-e465-4fef-ac63-96f4242b930e","networkId":56,"slug":"btc-price-on-october-25-4pm-utc","title":"BTC price on October 25, 4PM UTC","description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","state":"open","resolvedOutcomeIndex":null,"expiresAt":"2026-10-25T16:00:00.000Z","publishedAt":"2026-10-19T10:03:31.062Z","negRisk":false,"negRiskId":null,"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"volume":289.76636964,"volume24h":43.57827537,"volumeNotional":551.2490518,"volumeNotional24h":88.5709889,"liquidity":3615.38811412,"users":16,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"externalSources":[{"id":"88b4c1ba-219d-45c3-ba1e-f9f852726373","providerName":"polymarket","externalMarketId":"1212409","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"Bitcoin above ___ on October 25?"}],"sortByProbability":false,"scoreboard":null,"markets":[{"id":3722,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-80000-on-october-2","title":"BTC above $80,000 on October 25, 4PM UTC?","shortName":"Above 80,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:13.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":1092.43725892,"liquidityPrice":0,"volume":61.8740028,"volume24h":13.6705618,"volumeNotional":102.47097036,"volumeNotional24h":27.3411236,"users":3,"shares":32.34117036,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xd657025533626758DC5690DA581b28908df81955","0xfAaC195CaC01fD136A69f5640277A6B99DE3DD8F"],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.91,"closingPrice":null,"priceChange24h":0.12345679,"bestBid":0.89,"bestAsk":0.93,"imageUrl":null,"holders":0,"tokenId":"7444"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.09,"closingPrice":null,"priceChange24h":-0.52631579,"bestBid":0.07,"bestAsk":0.11,"imageUrl":null,"holders":0,"tokenId":"7445"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":0,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"80000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"ff95a6e8-d34f-434c-81b7-5b81cc124482","providerName":"polymarket","externalMarketId":"5889110","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $80,000 on October 25, 4PM UTC?"}]},{"id":3723,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-82000-on-october-2","title":"BTC above $82,000 on October 25, 4PM UTC?","shortName":"Above 82,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:22.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":1559.88754302,"liquidityPrice":0,"volume":100.96413963,"volume24h":15.20601865,"volumeNotional":204.78946926,"volumeNotional24h":37.4379653,"users":5,"shares":28.16356338,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xdc2F309C46fF654423EEd8413322d5dB06b94bEE","0x35318492254e1Ad3C642F2DEd2930856B17F9B27"],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.36,"closingPrice":null,"priceChange24h":-0.17241379,"bestBid":0.34,"bestAsk":0.38,"imageUrl":null,"holders":0,"tokenId":"7446"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.64,"closingPrice":null,"priceChange24h":0.13274336,"bestBid":0.62,"bestAsk":0.66,"imageUrl":null,"holders":0,"tokenId":"7447"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":1,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"82000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"781adbb4-590b-4582-99ff-f6d006128b2c","providerName":"polymarket","externalMarketId":"5889111","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $82,000 on October 25, 4PM UTC?"}]},{"id":3724,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-84000-on-october-2","title":"BTC above $84,000 on October 25, 4PM UTC?","shortName":"Above 84,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:30.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":655.53708846,"liquidityPrice":0,"volume":123.38143651,"volume24h":14.70169492,"volumeNotional":236.89503078,"volumeNotional24h":23.7919,"users":4,"shares":100.92929834,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xcE3a0442A230F3b1DBc495d97ae904b835712318"],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.03,"closingPrice":null,"priceChange24h":-0.71428571,"bestBid":0.02,"bestAsk":0.04,"imageUrl":null,"holders":0,"tokenId":"7448"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.97,"closingPrice":null,"priceChange24h":0.08379888,"bestBid":0.96,"bestAsk":0.98,"imageUrl":null,"holders":0,"tokenId":"7449"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":2,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"84000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"6dca79b7-4043-4fd2-bb8a-63e03537e841","providerName":"polymarket","externalMarketId":"5889113","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $84,000 on October 25, 4PM UTC?"}]},{"id":3725,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-86000-on-october-2","title":"BTC above $86,000 on October 25, 4PM UTC?","shortName":"Above 86,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:39.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":153.76311186,"liquidityPrice":0,"volume":3.5467907,"volume24h":0,"volumeNotional":7.0935814,"volumeNotional24h":0,"users":4,"shares":7.0935814,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xb1d7D5E97Bd7eF18d11137D433b15E42369Dc83f","0xc9e933f80dA9942cA101ae6131287852e60aFd00"],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.01,"closingPrice":null,"priceChange24h":-0.33333333,"bestBid":null,"bestAsk":0.02,"imageUrl":null,"holders":0,"tokenId":"7450"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.99,"closingPrice":null,"priceChange24h":0.00507614,"bestBid":0.98,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"7451"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":3,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"86000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"b66f6646-e2c5-46bb-987a-2d73a35c64ae","providerName":"polymarket","externalMarketId":"5889115","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $86,000 on October 25, 4PM UTC?"}]},{"id":3726,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-88000-on-october-2","title":"BTC above $88,000 on October 25, 4PM UTC?","shortName":"Above 88,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:47.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":153.76311186,"liquidityPrice":0,"volume":0,"volume24h":0,"volumeNotional":0,"volumeNotional24h":0,"users":0,"shares":0,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":[],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.01,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":0.02,"imageUrl":null,"holders":0,"tokenId":"7452"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.99,"closingPrice":null,"priceChange24h":0,"bestBid":0.98,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"7453"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":4,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"88000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"6f8dd854-ce69-4fb0-9fa7-ff8d53bc52a3","providerName":"polymarket","externalMarketId":"5889117","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $88,000 on October 25, 4PM UTC?"}]}]} \ No newline at end of file diff --git a/tests/samples/myriad_grouped.json b/tests/samples/myriad_grouped.json new file mode 100644 index 0000000..97249ed --- /dev/null +++ b/tests/samples/myriad_grouped.json @@ -0,0 +1 @@ +{"data":[{"type":"market","id":120473,"networkId":56,"slug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","title":"Crude Oil’s next move: Pump to $120 or Dump to $55?","shortName":null,"ctaName":null,"description":"### **Market Details:**\n\n- **Market Close:** This market will only be closed once a resolution is achieved.\n- **Resolution Deadline:** The resolution will be determined as soon as an outcome is reached.\n- **Pump Target:** 120.000\n- **Dump Target**: 55.000\n\n### **Resolution Criteria:**\n\n- The market resolves based on which condition is met first:\n - **“$120”** if the Crude Oil WTI (USD/Bbl) at Trading Economics reaches or exceeds the Pump Target.\n - **“$55”** if the Crude Oil WTI (USD/Bbl) at Trading Economics drops to equal or below the Dump Target.\n\n### **Resolution Details:**\n\n- The market resolves based on the one-minute (1m) price updates on the Crude Oil WTI (USD/Bbl) chart on the Trading Economics website.\n- Only the Crude Oil WTI (USD/Bbl) on Trading Economics will be considered.\n\n### **Cancellation (Invalidity) Conditions:**\n\n- The [Crude Oil WTI (USD/Bbl) at Trading Economics](https://tradingeconomics.com/commodity/crude-oil) becomes unavailable, compromised, or fails to update for seven days straight.\n- Any circumstance prevents reliable price tracking.\n- Myriad Markets undergoes a change in its contract that demands a cancellation of all active markets, or a similar significant technical change.\n\nIn the event of cancellation, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-03-11T15:04:29.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0,"treasury_fee":0,"distributor_fee":0},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://tradingeconomics.com/commodity/crude-oil","resolutionTitle":"Trading Economics","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/e11bc0f6-a97f-4e7b-981e-2340057bb700/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/0d78a1d7-2ce2-469b-2646-710dd71af400/public","liquidity":1500,"liquidityPrice":0.84818529,"volume":8581799.62382441,"volume24h":1271.0737,"volumeNotional":13823192.82922941,"volumeNotional24h":2151.28463759,"users":4091,"shares":2893.87881734,"featured":true,"featuredAt":"2027-05-15T15:56:25.212Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x3288F8C54C9C63D55Ba159e8B6076FcA2960c602","0x6fD3a753943802d0F42B6b885d0Ee496116550b3","0xCEe198c0e83a969768e061c5f5aFD59700087AE2"],"outcomes":[{"id":0,"title":"$120","shares":944.22223028,"sharesHeld":4334.92809017,"price":0.67371743,"closingPrice":null,"priceChange24h":0.07796115,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","holders":98,"tokenId":"240946"},{"id":1,"title":"$55","shares":1949.65658706,"sharesHeld":3329.49373339,"price":0.32628257,"closingPrice":null,"priceChange24h":-0.12981601,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":30,"tokenId":"240947"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"type":"market","id":120550,"networkId":56,"slug":"will-george-russell-win-the-f1-drivers-championship-2026","title":"Will George Russell win the F1 Drivers Championship 2026?","shortName":null,"ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period:** From the publication date until December 5, 2026, at 2:00 PM UTC.\n- **Market Close:** December 5, 2026, at 2:00 PM UTC, ahead of the last Qualifying.\n- **Resolution Deadline:** The resolution will be determined after the results are published on the resolution source.\n\n### **Resolution Criteria:**\n\n- Resolves to “**Yes**” if George Russel is named the official 2026 F1 Drivers Championship winner.\n- Resolves to “**No**” if any other driver wins.\n\n### **Resolution Details:**\n\n- Any points adjustments, penalties, or disqualifications announced before the FIA officially certifies the final standings will be included.\n- Post-season changes announced after official certification will not affect resolution.\n\n### **Cancelation (Invalidity) Conditions:**\n\n- The 2026 FIA Formula One season is canceled, abandoned, or not completed in a manner that produces official final Drivers’ standings.\n- The FIA fails to publish official final standings.\n- Any circumstance makes it impossible to definitively determine the Drivers’ Champion.\n- The resolution source becomes unavailable, unreliable, or fails to provide an official outcome.","publishedAt":"2026-03-11T16:06:17.000Z","expiresAt":"2026-12-05T14:00:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0.01,"treasury_fee":0,"distributor_fee":0.01},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Sports"],"tags":[],"resolutionSource":"https://www.formula1.com/en/results/2026/drivers","resolutionTitle":"F1","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmeeSkM7xfSUQCBL2DeYwKxRm5ztfQbfBKs47sNJZVVc46","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/88f436f2-e358-4157-3e37-d52e4c781800/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/3238daf3-692f-479d-f470-917fe8128100/public","liquidity":1500,"liquidityPrice":0.37866481,"volume":10428.63001261,"volume24h":420.9915,"volumeNotional":15401.34452455,"volumeNotional24h":433.84890718,"users":53,"shares":6482.10600668,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x094ff2a1a82E52aC6489fE082b9486c8C93eEE3b","0xFd56913387EA84843DD37b83F65Fe47D65BE3fee","0x6fD3a753943802d0F42B6b885d0Ee496116550b3"],"outcomes":[{"id":0,"title":"Yes","shares":6184.43804094,"sharesHeld":1092.37835014,"price":0.04592149,"closingPrice":null,"priceChange24h":-0.12304954,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmeeSkM7xfSUQCBL2DeYwKxRm5ztfQbfBKs47sNJZVVc46","holders":8,"tokenId":"241100"},{"id":1,"title":"No","shares":297.66796574,"sharesHeld":6979.14842534,"price":0.95407851,"closingPrice":null,"priceChange24h":0.00679954,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":14,"tokenId":"241101"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"type":"market","id":120406,"networkId":56,"slug":"will-democrats-sweep-the-2026-u-s-midterm-elections","title":"Will Democrats sweep the 2026 U.S. Midterm Elections?","shortName":null,"ctaName":null,"description":"## **Market Dates:**\n\n- **Market Period:** From the publication date until November 3, 2026 at 1:00 AM UTC.\n- **Market Close:** November 3, 2026 at 1:00 AM UTC.\n- **Resolution Time:** The outcome will be determined after Market Close, once the 2026 US Midterm Elections results are officially confirmed by the market resolution sources.\n\n## **Resolution Criteria:**\n\n- Resolves to **“Yes”** if the Democratic Party controls **both the U.S. House of Representatives and the U.S. Senate** following the 2026 United States midterm elections.\n- Resolves to **“No”** if the Democratic Party does not control both chambers after the elections.\n\n## **Resolution Criteria:**\n\n- For the purposes of this market, a “Democratic sweep” means the Democratic Party controls both the U.S. House of Representatives and the U.S. Senate following the election.\n- A party will be considered to have control of the House of Representatives if it wins a majority of voting seats.\n- A party will be considered to have control of the Senate if it holds:\n - More than half of the voting Senate seats, or\n - Half of the seats plus the Vice President, who can cast tie-breaking votes.\n- A candidate’s party affiliation will be determined by their ballot-listed or otherwise identifiable affiliation at the time the election results are conclusively called by the resolution sources.\n- Candidates without a ballot-listed affiliation to either the Democratic or Republican Parties will be considered aligned with the party they most recently expressed intent to caucus with at the time results are finalized.\n- If control of the House remains ambiguous, the market will resolve according to the party affiliation of the first Speaker of the U.S. House selected following the 2026 midterm elections.\n- If control of the Senate remains ambiguous, the market will resolve according to the party affiliation of the first Senate Majority Leader selected following the elections.\n\n## **Resolution Details:**\n\n- This market will resolve once all the following sources have conclusively called winners determining control of the House and Senate.\n - [Reuters](https://www.reuters.com/)\n - [Associated Press](https://apnews.com/)\n - [Fox News](https://www.foxnews.com/)\n- If the three sources **do not reach consensus**, this market will resolve according to the **official certification of election results**.\n\n## **Cancellation (Invalidity) Conditions**\n\n- The 2026 U.S. midterm elections are not held.\n- Determining control of Congress becomes impossible due to extraordinary circumstances preventing certification or recognition of results.\n- Myriad Markets undergoes a major technical or contractual change requiring cancellation of active markets.\n\nIf canceled, participants may claim their stakes at the market value of their open positions at the time of cancellation.","publishedAt":"2026-03-11T14:10:51.000Z","expiresAt":"2026-11-03T01:00:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0.01,"treasury_fee":0,"distributor_fee":0.01},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Politics"],"tags":[],"resolutionSource":"https://apnews.com","resolutionTitle":"APNews","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmcC48r1A8Jrdn61YWYPNGwktBci4vCyyUGkMn91D83caa","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/0bd92a13-587e-49ed-8ad7-b1f62811db00/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/91cfe033-4575-4014-9147-c6574b79bd00/public","liquidity":1500.00004859,"liquidityPrice":0.86525874,"volume":4606.8942516,"volume24h":187.7501,"volumeNotional":8571.61061427,"volumeNotional24h":315.48700777,"users":58,"shares":2836.77634159,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x226a1324603dA898c0736d971A1FEA6cdfad206F","0xF7161633812d283e31e552757f62B51358c8Cc5E","0x7836D709E750F6A2D6ff9362457028e8Cd901EB3"],"outcomes":[{"id":0,"title":"Yes","shares":1004.96853789,"sharesHeld":1453.73793195,"price":0.64573572,"closingPrice":null,"priceChange24h":-0.04428625,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmcC48r1A8Jrdn61YWYPNGwktBci4vCyyUGkMn91D83caa","holders":18,"tokenId":"240812"},{"id":1,"title":"No","shares":1831.80780371,"sharesHeld":626.89866613,"price":0.35426428,"closingPrice":null,"priceChange24h":0.09225562,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":7,"tokenId":"240813"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"type":"market","id":143344,"networkId":56,"slug":"gold-price-pump-to-4-900-or-dump-to-3-700","title":"GOLD Price: Pump to $4.900 or Dump to $3.700?","shortName":null,"ctaName":null,"description":"### **Market Details:**\n\n- **Market Close:** This market will only be closed once a resolution is achieved.\n- **Resolution Deadline:** The resolution will be determined as soon as an outcome is reached.\n- **Pump Target:** $4,900.000\n- **Dump Target**: $3,700.000\n\n### **Resolution Criteria:**\n\n- The market resolves based on which condition is met first:\n - **“$4900”** if the Gold (USD/t.oz) price on Trading Economics reaches or exceeds the Pump Target.\n - **“$3700”** if the Gold (USD/t.oz) price on Trading Economics drops to equal or below the Dump Target.\n\n### **Resolution Details:**\n\n- The Trading Economics closing price of Gold (USD/t.oz) for the Resolution Date will be used, specifically:\n - One-Day view timeframe (“1D”)\n - Gold (USD/t.oz) price\n - Only the Gold (USD/t.oz) price reported by Trading Economics will be considered.\n\n### **Cancellation Conditions:**\n\n- The resolution source becomes unavailable, unreliable or experiences major changes.\n- Any circumstance that prevents reliable price tracking at the Resolution Deadline.\n- Myriad Markets undergoes a change in its contract that demands a cancelation of all active markets, or a similar significant technical change.\n\nIn the event of cancellation, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-03-24T20:14:37.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0.01,"treasury_fee":0,"distributor_fee":0.01},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://tradingeconomics.com/commodity/gold","resolutionTitle":"Trading Economics","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmaLkJqqk4anYggbF5PmcE5A4j1zkqRJe7wSyf2VC8P4dn","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/0a8c2124-445f-40e8-2f91-cdc2617c4200/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/f62abd4c-974f-4511-9d00-13800bc56600/public","liquidity":1000.00005746,"liquidityPrice":0.95207828,"volume":22841.26864044,"volume24h":185.5903,"volumeNotional":53193.1562916,"volumeNotional24h":378.22955793,"users":59,"shares":2100.66771907,"featured":false,"featuredAt":"2026-04-15T08:50:55.604Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x14d29370Ec944C505E9BCddce69B8E87BDBd314E","0xCEe198c0e83a969768e061c5f5aFD59700087AE2","0x78F81cA82fdfbF0a2C0fCA3bCF710cD2F5235005"],"outcomes":[{"id":0,"title":"$4900","shares":1371.58314215,"sharesHeld":3179.94609226,"price":0.34707278,"closingPrice":null,"priceChange24h":-0.20121719,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmaLkJqqk4anYggbF5PmcE5A4j1zkqRJe7wSyf2VC8P4dn","holders":16,"tokenId":"286688"},{"id":1,"title":"$3700","shares":729.08457693,"sharesHeld":3822.44465749,"price":0.65292722,"closingPrice":null,"priceChange24h":0.15460584,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":8,"tokenId":"286689"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"type":"market","id":23912,"networkId":56,"slug":"will-discord-s-ipo-closing-market-cap-be-above-20-billion-2d759207-9227-4b58-930e-7ffec8dd9049","title":"Will Discord’s IPO Closing Market Cap be above $20 Billion?","shortName":null,"ctaName":null,"description":"### **Market Details:**\n\n- **Market Close:** This market will close prior to the IPO of Discord, and will be adjusted to this once the date is officially announced.\n- **Resolution Time:** The resolution will be determined as soon as Discord’s stock has completed its first day of public trading.\n- **Market Target:** $20,000,000,000 (20 Billion USD).\n\n### **Resolution Criteria:**\n\n- Resolves to “**Yes**\" if the closing market cap of Discord is above the Market Target at the end of its first day of public trading.\n- Resolves to \"**No**\" if not.\n\n### **Resolution Details:**\n\n- This market will resolve based on the official data from Nasdaq.\n- In case Discord doesn’t begin trading on Nasdaq, the resolution source will be adapted to the stock exchange it begins trading on instead.\n- Only the market capitalization of Discord at the stock market close of its first day of trading is of subject to this market.\n- Market capitalization refers to the product of the total outstanding shares multiplied by the closing share price on the first day the stock is traded.\n- In case the primary resolution source does not report on the market cap or the number of outstanding shares then official Discord communications or a media consensus of credible reporting will be used to determine the amount of outstanding shares.\n- If normal trading on Discord’s first trading day is disrupted (for example, due to a circuit breaker or a shortened session), the market will settle based on the official closing price from that shortened session. If no official closing price is issued, the market will instead settle using the official closing price from the next trading day on which one is published, which will be considered the first trading day.\n\n### **Cancellation Conditions:**\n\n- Discord doesn’t IPO before 2028.\n- Any circumstance makes it impossible to determine the outcome.\n- Myriad Markets undergoes a change in its contract that demands a cancellation of all active markets, or a similar significant technical change.\n\nIn the event of cancellation, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-01-14T14:55:29.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0.01,"treasury_fee":0,"distributor_fee":0.01},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://www.nasdaq.com/","resolutionTitle":"Nasdaq","token":{"name":"Tether USD","address":"0x55d398326f99059fF775485246999027B3197955","symbol":"USDT","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmcwKUHGaBaF66tyvgobt1MnhsGhWbJQgKrKi6jLiYuiet","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/027475e0-b783-41d7-ea7f-37dfb87e9800/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ca9efd52-02bb-4462-1ca5-93121e3e8500/public","liquidity":1500,"liquidityPrice":0.95942381,"volume":7995.15622889,"volume24h":185.0282,"volumeNotional":14363.73077638,"volumeNotional24h":279.90411796,"users":51,"shares":3126.87674507,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0xE88369236CD06c7075733ACB19473730ec8331Fe","0xCEe198c0e83a969768e061c5f5aFD59700087AE2","0xdc2F309C46fF654423EEd8413322d5dB06b94bEE"],"outcomes":[{"id":0,"title":"Yes","shares":2004.27796322,"sharesHeld":363.47191378,"price":0.359016,"closingPrice":null,"priceChange24h":0.12380967,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmcwKUHGaBaF66tyvgobt1MnhsGhWbJQgKrKi6jLiYuiet","holders":7,"tokenId":"47824"},{"id":1,"title":"No","shares":1122.59878185,"sharesHeld":1245.15109515,"price":0.640984,"closingPrice":null,"priceChange24h":-0.0581198,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":13,"tokenId":"47825"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"type":"market","id":49327,"networkId":56,"slug":"how-much-gold-will-it-take-to-buy-one-bitcoin-next-e469154d-aa2c-4ccc-8ceb-2e2158f3e18d","title":"How much Gold will it take to buy one Bitcoin next?","shortName":null,"ctaName":null,"description":"### **Market Details:**\n\n- **Market Close:** This market will only be closed once a resolution is achieved.\n- **Resolution Deadline:** The resolution will be determined as soon as an outcome is reached.\n- **Market Targets:**\n - **10 oz Target:** 1 Bitcoin = 10.0000 ounces of Gold.\n - **30 oz Target**: 1 Bitcoin = 30.0000 ounces of Gold.\n\n### **Resolution Criteria:**\n\n- The market resolves based on which condition is met first:\n - **“30 oz”** if the Bitcoin to Gold Ratio on Longterm Trends reaches or exceeds the 30 oz Target.\n - **“10 oz”** if the Bitcoin to Gold Ratio on Longterm Trends drops to equal or below the 10 oz Target.\n\n### **Resolution Details:**\n\nThe market resolves based on the BItcoin to Gold Ratio Chart (logarithmic scale) on Longterm Trends, specifically:\n\n- 1-Year view (\"1y\").\n- Bitcoin / Gold ratio in ounces for each day\n\nOnly the “Bitcoin / Gold Ratio” value as quoted by Longterm Trends will be considered.\n\n### **Cancellation (Invalidity) Conditions:**\n\n- The BItcoin / Gold Ratio on Longterm Trends is suspended or becomes unreliable.\n- The Longterm Trends platform is unavailable or experiences significant disruptions.\n- Any circumstance prevents reliable price tracking.\n- Myriad Markets undergoes a change in its contract that demands a cancellation of all active markets, or a similar significant technical change.\n\nIn the event of cancellation, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-01-29T12:07:38.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0.01,"treasury_fee":0,"distributor_fee":0.01},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto","Economy"],"tags":[],"resolutionSource":"https://www.longtermtrends.com/bitcoin-vs-gold/","resolutionTitle":"Longterm Trends","token":{"name":"Tether USD","address":"0x55d398326f99059fF775485246999027B3197955","symbol":"USDT","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmPEyDGGhFPqfTwxovAzF1icd5Xq8bb6fAXmWt1wpfRBpK","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/06533ba4-1c6d-479f-6f38-9fd710b18200/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ae927247-5f6f-489c-bdb3-1c7567a67300/public","liquidity":1000,"liquidityPrice":0.90453134,"volume":10829.55234456,"volume24h":99.9915,"volumeNotional":23073.36643405,"volumeNotional24h":207.3209985,"users":59,"shares":1809.07346363,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0xF7161633812d283e31e552757f62B51358c8Cc5E","0xF1Ca48D73B31f56b2f20B4eDF2c46Bc3d89Bb744","0x226a1324603dA898c0736d971A1FEA6cdfad206F"],"outcomes":[{"id":0,"title":"10 oz","shares":906.74603488,"sharesHeld":3085.03436757,"price":0.49877877,"closingPrice":null,"priceChange24h":-0.10247976,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmPEyDGGhFPqfTwxovAzF1icd5Xq8bb6fAXmWt1wpfRBpK","holders":14,"tokenId":"98654"},{"id":1,"title":"30 oz","shares":902.32742875,"sharesHeld":3089.4529737,"price":0.50122123,"closingPrice":null,"priceChange24h":0.12819015,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":8,"tokenId":"98655"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"type":"event","id":"2d776610-35da-47d3-869d-f898e56ca5e3","networkId":56,"slug":"which-movie-will-have-the-biggest-opening-weekend-of-2026","title":"Which movie will have the biggest opening weekend of 2026?","description":"### **Market Dates:**\n\n- **Market Period:** From the publication date through December 31, 2026, at 11:59 PM ET.\n- **Market Close:** December 31, 2026, at 11:59 PM ET.\n- **Resolution Time:** Once final 3-day opening weekend figures are published for all relevant 2026 releases, expected shortly after January 1, 2027.\n\n### **Resolution Criteria:**\n\n- Resolves to \"Yes\" for the film that grosses more on its 3-day domestic opening weekend than any other movie released in 2026, provided it is listed as an outcome in this market.\n- Resolves to \"No\" for all other listed films.\n- Resolution is based specifically on the 3-day opening weekend figure (Friday, Saturday, Sunday), regardless of whether a film has a 4- or 5-day opening weekend.\n- The figures used are those reported by The Numbers under \"Weekend Box Office Performance,\" regardless of whether \"domestic\" refers to the US only, or to the US and Canada combined.\n- If another film's opening weekend surpasses that of a listed film after figures for both are finalized, the affected outcome may immediately resolve to \"No\".\n- In the event of an exact tie, the film whose title comes first alphabetically will be declared the winner.\n\n### **Resolution Details:**\n\n- This market will be resolved using the \"Weekend Box Office Performance\" figures published on each film's page at [https://www.the-numbers.com/movies/year/2026](https://www.the-numbers.com/movies/year/2026), once those figures are final (i.e., not studio estimates).\n- If no final data is available by January 31, 2027, 11:59 PM ET, another credible resolution source will be chosen.\n\n### **Cancellation Conditions:**\n\n- The resolution source is compromised or unavailable for prolonged periods.\n- Any circumstance makes it impossible to determine the outcome definitively.\n\nIf the market is cancelled, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/SiM4RPiObJIObbkGsyGUKWUAodIpm7ANNSsoSirFV9A/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/Nq22U9wfbE2RT9aepROFgEYGIqTBZcNCndu82n0Nnww/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/6fa49901-acdb-4375-8e31-d39ff60b4a00/public","state":"open","resolvedOutcomeIndex":null,"expiresAt":"2027-01-01T04:59:00.000Z","publishedAt":"2026-09-18T17:04:19.960Z","negRisk":false,"negRiskId":null,"tags":[],"volume":7924.37201966,"volume24h":98,"volumeNotional":15756.21123938,"volumeNotional24h":196,"liquidity":1011,"users":40,"featured":true,"featuredAt":"2026-10-23T15:00:00.571Z","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"externalSources":[{"id":"fd83b2c4-5f8c-484e-8075-9a3822475469","providerName":"polymarket","externalMarketId":"79836","externalMarketUrl":"https://polymarket.com/event/which-movie-has-biggest-opening-weekend-in-2026","externalMarketTitle":"Which movie has biggest opening weekend in 2026?"}],"sortByProbability":true,"scoreboard":null,"markets":[{"id":3160,"networkId":56,"slug":"which-movie-will-have-the-biggest-opening-weekend-of-2026-wi-a8a8b6cc","title":"Will \"Avengers: Doomsday\" have the best domestic opening weekend in 2026?","shortName":"Avengers: Doomsday","ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period:** From the publication date through December 31, 2026, at 11:59 PM ET.\n- **Market Close:** December 31, 2026, at 11:59 PM ET.\n- **Resolution Time:** Once final 3-day opening weekend figures are published for all relevant 2026 releases, expected shortly after January 1, 2027.\n\n### **Resolution Criteria:**\n\n- Resolves to \"Yes\" for the film that grosses more on its 3-day domestic opening weekend than any other movie released in 2026, provided it is listed as an outcome in this market.\n- Resolves to \"No\" for all other listed films.\n- Resolution is based specifically on the 3-day opening weekend figure (Friday, Saturday, Sunday), regardless of whether a film has a 4- or 5-day opening weekend.\n- The figures used are those reported by The Numbers under \"Weekend Box Office Performance,\" regardless of whether \"domestic\" refers to the US only, or to the US and Canada combined.\n- If another film's opening weekend surpasses that of a listed film after figures for both are finalized, the affected outcome may immediately resolve to \"No\".\n- In the event of an exact tie, the film whose title comes first alphabetically will be declared the winner.\n\n### **Resolution Details:**\n\n- This market will be resolved using the \"Weekend Box Office Performance\" figures published on each film's page at [https://www.the-numbers.com/movies/year/2026](https://www.the-numbers.com/movies/year/2026), once those figures are final (i.e., not studio estimates).\n- If no final data is available by January 31, 2027, 11:59 PM ET, another credible resolution source will be chosen.\n\n### **Cancellation Conditions:**\n\n- The resolution source is compromised or unavailable for prolonged periods.\n- Any circumstance makes it impossible to determine the outcome definitively.\n\nIf the market is cancelled, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-09-18T17:03:42.000Z","expiresAt":"2027-01-01T04:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":98,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,98,96,94,92,90,88,86,84,82,80,78,76,74,72,70,68,66,64,62,60,58,56,54,52,50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14,12,10,8,6,4,2,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Culture"],"tags":[],"resolutionSource":"https://www.the-numbers.com/movies/year/2026","resolutionTitle":"The Numbers","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/uH_OzD5mRU8z5_qrCjUBOoUOiKY1yHkCucOU1K8KHZU/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/Nq22U9wfbE2RT9aepROFgEYGIqTBZcNCndu82n0Nnww/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/6fa49901-acdb-4375-8e31-d39ff60b4a00/public","liquidity":1011,"liquidityPrice":0,"volume":3221.73245925,"volume24h":0,"volumeNotional":6391.3321185,"volumeNotional24h":0,"users":18,"shares":18.50311852,"featured":true,"featuredAt":"2026-10-23T15:00:00.571Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x098e108870D067e78F94cd1d0A9e04c7A0D06e40","0xfAaC195CaC01fD136A69f5640277A6B99DE3DD8F","0x6Ad3ef324183E95855346e693c3CF373b35de785"],"outcomes":[{"id":0,"title":"Yes","shares":9.25155926,"sharesHeld":9.25155926,"price":0.54,"closingPrice":null,"priceChange24h":0.03846154,"bestBid":0.08,"bestAsk":null,"imageUrl":null,"holders":2,"tokenId":"6320"},{"id":1,"title":"No","shares":9.25155926,"sharesHeld":9.25155926,"price":0.46,"closingPrice":null,"priceChange24h":-0.04166667,"bestBid":null,"bestAsk":0.92,"imageUrl":null,"holders":2,"tokenId":"6321"}],"eventId":"2d776610-35da-47d3-869d-f898e56ca5e3","outcomeIndex":1,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"a9f8ea40-cdc8-403d-8e28-31408dbeb561","providerName":"polymarket","externalMarketId":"678447","externalMarketUrl":"https://polymarket.com/event/which-movie-has-biggest-opening-weekend-in-2026","externalMarketTitle":"Will \"Avengers: Doomsday\" have the best domestic opening weekend in 2026?"}]},{"id":3159,"networkId":56,"slug":"which-movie-will-have-the-biggest-opening-weekend-of-2026-wi","title":"Will \"Spider-Man: Brand New Day\" have the best domestic opening weekend in 2026?","shortName":"Spider-Man: Brand New Day","ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period:** From the publication date through December 31, 2026, at 11:59 PM ET.\n- **Market Close:** December 31, 2026, at 11:59 PM ET.\n- **Resolution Time:** Once final 3-day opening weekend figures are published for all relevant 2026 releases, expected shortly after January 1, 2027.\n\n### **Resolution Criteria:**\n\n- Resolves to \"Yes\" for the film that grosses more on its 3-day domestic opening weekend than any other movie released in 2026, provided it is listed as an outcome in this market.\n- Resolves to \"No\" for all other listed films.\n- Resolution is based specifically on the 3-day opening weekend figure (Friday, Saturday, Sunday), regardless of whether a film has a 4- or 5-day opening weekend.\n- The figures used are those reported by The Numbers under \"Weekend Box Office Performance,\" regardless of whether \"domestic\" refers to the US only, or to the US and Canada combined.\n- If another film's opening weekend surpasses that of a listed film after figures for both are finalized, the affected outcome may immediately resolve to \"No\".\n- In the event of an exact tie, the film whose title comes first alphabetically will be declared the winner.\n\n### **Resolution Details:**\n\n- This market will be resolved using the \"Weekend Box Office Performance\" figures published on each film's page at [https://www.the-numbers.com/movies/year/2026](https://www.the-numbers.com/movies/year/2026), once those figures are final (i.e., not studio estimates).\n- If no final data is available by January 31, 2027, 11:59 PM ET, another credible resolution source will be chosen.\n\n### **Cancellation Conditions:**\n\n- The resolution source is compromised or unavailable for prolonged periods.\n- Any circumstance makes it impossible to determine the outcome definitively.\n\nIf the market is cancelled, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-09-18T17:03:33.000Z","expiresAt":"2027-01-01T04:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":98,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,98,96,94,92,90,88,86,84,82,80,78,76,74,72,70,68,66,64,62,60,58,56,54,52,50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14,12,10,8,6,4,2,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Culture"],"tags":[],"resolutionSource":"https://www.the-numbers.com/movies/year/2026","resolutionTitle":"The Numbers","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/els5_Z5-DEsCL0ru0fLKwqeLpfZelf8CxXzi0L6AP28/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/Nq22U9wfbE2RT9aepROFgEYGIqTBZcNCndu82n0Nnww/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/6fa49901-acdb-4375-8e31-d39ff60b4a00/public","liquidity":0,"liquidityPrice":0,"volume":4702.63956041,"volume24h":98,"volumeNotional":9364.87912088,"volumeNotional24h":196,"users":22,"shares":1161.3076923,"featured":true,"featuredAt":"2026-10-23T15:00:00.571Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0xcE3a0442A230F3b1DBc495d97ae904b835712318","0x634AC729D913da82FDBfA365343E0024753F0B48","0xF7161633812d283e31e552757f62B51358c8Cc5E"],"outcomes":[{"id":0,"title":"Yes","shares":580.65384615,"sharesHeld":580.65384615,"price":0.445,"closingPrice":null,"priceChange24h":-0.07291667,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":1,"tokenId":"6318"},{"id":1,"title":"No","shares":580.65384615,"sharesHeld":580.65384615,"price":0.555,"closingPrice":null,"priceChange24h":0.06730769,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":6,"tokenId":"6319"}],"eventId":"2d776610-35da-47d3-869d-f898e56ca5e3","outcomeIndex":0,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"8b608091-5158-4403-96a7-1efc6bcc00b0","providerName":"polymarket","externalMarketId":"678445","externalMarketUrl":"https://polymarket.com/event/which-movie-has-biggest-opening-weekend-in-2026","externalMarketTitle":"Will \"Spider-Man: Brand New Day\" have the best domestic opening weekend in 2026?"}]}]},{"type":"event","id":"12f1348d-da61-45e8-95c3-8380c86b61eb","networkId":56,"slug":"feds-interest-rate-decision-in-october","title":"FED’s interest rate decision in October?","description":"### **Market Dates:**\n\n- **Market Period**: From the publication date through October 28, 2026, at 11:59 PM UTC.\n- **Market Close:** October 28, 2026, at 11:59 PM UTC.\n- **Resolution Deadline:** The resolution will be determined after the outcome is announced.\n\n### **Resolution Criteria:**\n\n- This market will resolve according to the amount of basis points by which the upper bound of the target federal funds rate changes relative to the level in place before the Federal Reserve’s October 2026 meeting. For example, if the FED decides to keep rates the same, this market will resolve “**Yes**” for “**No change**” and **“No”** for all other listed options.\n\n### **Resolution Details:**\n\n- This market will resolve based on the official FOMC statement released following its October 27-28, 2026 meeting. If no statement is released before the end date of the subsequent scheduled meeting, the market will resolve to “Yes” for the “No change” bracket.\n- If the target federal funds rate is adjusted to a level not represented by the listed options, the change will be rounded up to the nearest 25 basis points and resolved to the corresponding bracket. For example, a 12.5 bps increase or decrease will be treated as a 25 bps change.\n\n### **Cancellation Conditions:**\n\n- The resolution source is suspended, becomes unreliable, or experiences significant disruptions during the Market Period.\n\nIf cancelled, participants may claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on their open positions.","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/td15x8iBFOWXVKLseWQXnv1wbnLwxuG8_qXdZjaqYzQ/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/sbr7Oovq0hYcm14hCQ0I9m3K786aozx9nbVzbNEAzaI/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ae98808d-f65c-4c67-7239-49f8cbd33700/public","state":"open","resolvedOutcomeIndex":null,"expiresAt":"2026-10-28T23:59:00.000Z","publishedAt":"2026-09-22T09:43:25.320Z","negRisk":false,"negRiskId":null,"tags":[],"volume":9303.78286906,"volume24h":82,"volumeNotional":18473.56573812,"volumeNotional24h":164,"liquidity":97.58,"users":48,"featured":true,"featuredAt":"2026-10-23T15:00:00.575Z","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"externalSources":[{"id":"8710591b-eb98-4a41-a8f1-dc137b2c9627","providerName":"polymarket","externalMarketId":"606422","externalMarketUrl":"https://polymarket.com/event/fed-decision-in-october-20260617190323537","externalMarketTitle":"Fed Decision in October?"}],"sortByProbability":true,"scoreboard":null,"markets":[{"id":3220,"networkId":56,"slug":"feds-interest-rate-decision-in-october-will-there-be-no-chan","title":"Will there be no change in Fed interest rates after the October 2026 meeting?","shortName":"No change","ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period**: From the publication date through October 28, 2026, at 11:59 PM UTC.\n- **Market Close:** October 28, 2026, at 11:59 PM UTC.\n- **Resolution Deadline:** The resolution will be determined after the outcome is announced.\n\n### **Resolution Criteria:**\n\n- This market will resolve according to the amount of basis points by which the upper bound of the target federal funds rate changes relative to the level in place before the Federal Reserve’s October 2026 meeting. For example, if the FED decides to keep rates the same, this market will resolve “**Yes**” for “**No change**” and **“No”** for all other listed options.\n\n### **Resolution Details:**\n\n- This market will resolve based on the official FOMC statement released following its October 27-28, 2026 meeting. If no statement is released before the end date of the subsequent scheduled meeting, the market will resolve to “Yes” for the “No change” bracket.\n- If the target federal funds rate is adjusted to a level not represented by the listed options, the change will be rounded up to the nearest 25 basis points and resolved to the corresponding bracket. For example, a 12.5 bps increase or decrease will be treated as a 25 bps change.\n\n### **Cancellation Conditions:**\n\n- The resolution source is suspended, becomes unreliable, or experiences significant disruptions during the Market Period.\n\nIf cancelled, participants may claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on their open positions.","publishedAt":"2026-09-22T09:42:32.000Z","expiresAt":"2026-10-28T23:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":98,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,98,96,94,92,90,88,86,84,82,80,78,76,74,72,70,68,66,64,62,60,58,56,54,52,50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14,12,10,8,6,4,2,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm","resolutionTitle":"FED","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/sbr7Oovq0hYcm14hCQ0I9m3K786aozx9nbVzbNEAzaI/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ae98808d-f65c-4c67-7239-49f8cbd33700/public","liquidity":97.58,"liquidityPrice":0,"volume":3589.12781564,"volume24h":82,"volumeNotional":7136.05563128,"volumeNotional24h":164,"users":20,"shares":996.05563128,"featured":true,"featuredAt":"2026-10-23T15:00:00.575Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0xdc2F309C46fF654423EEd8413322d5dB06b94bEE","0x45B3cc9Ac174b2BceEE5E489BE3C6c2782890739","0x2a6D61B38B700b47973B336f8cE437Ca79694C65"],"outcomes":[{"id":0,"title":"Yes","shares":498.02781564,"sharesHeld":498.02781564,"price":0.955,"closingPrice":null,"priceChange24h":0.01058201,"bestBid":0.91,"bestAsk":null,"imageUrl":null,"holders":3,"tokenId":"6440"},{"id":1,"title":"No","shares":498.02781564,"sharesHeld":498.02781564,"price":0.045,"closingPrice":null,"priceChange24h":-0.18181818,"bestBid":null,"bestAsk":0.09,"imageUrl":null,"holders":5,"tokenId":"6441"}],"eventId":"12f1348d-da61-45e8-95c3-8380c86b61eb","outcomeIndex":2,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"818a2fdc-163e-4b0f-9e7a-b0e29ef1dea7","providerName":"polymarket","externalMarketId":"2589812","externalMarketUrl":"https://polymarket.com/event/fed-decision-in-october-20260617190323537","externalMarketTitle":"Will there be no change in Fed interest rates after the October 2026 meeting?"}]},{"id":3221,"networkId":56,"slug":"feds-interest-rate-decision-in-october-will-the-fed-increase","title":"Will the Fed increase interest rates by 25 bps after the October 2026 meeting?","shortName":"25 bps increase","ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period**: From the publication date through October 28, 2026, at 11:59 PM UTC.\n- **Market Close:** October 28, 2026, at 11:59 PM UTC.\n- **Resolution Deadline:** The resolution will be determined after the outcome is announced.\n\n### **Resolution Criteria:**\n\n- This market will resolve according to the amount of basis points by which the upper bound of the target federal funds rate changes relative to the level in place before the Federal Reserve’s October 2026 meeting. For example, if the FED decides to keep rates the same, this market will resolve “**Yes**” for “**No change**” and **“No”** for all other listed options.\n\n### **Resolution Details:**\n\n- This market will resolve based on the official FOMC statement released following its October 27-28, 2026 meeting. If no statement is released before the end date of the subsequent scheduled meeting, the market will resolve to “Yes” for the “No change” bracket.\n- If the target federal funds rate is adjusted to a level not represented by the listed options, the change will be rounded up to the nearest 25 basis points and resolved to the corresponding bracket. For example, a 12.5 bps increase or decrease will be treated as a 25 bps change.\n\n### **Cancellation Conditions:**\n\n- The resolution source is suspended, becomes unreliable, or experiences significant disruptions during the Market Period.\n\nIf cancelled, participants may claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on their open positions.","publishedAt":"2026-09-22T09:42:41.000Z","expiresAt":"2026-10-28T23:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":98,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,98,96,94,92,90,88,86,84,82,80,78,76,74,72,70,68,66,64,62,60,58,56,54,52,50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14,12,10,8,6,4,2,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm","resolutionTitle":"FED","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/sbr7Oovq0hYcm14hCQ0I9m3K786aozx9nbVzbNEAzaI/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ae98808d-f65c-4c67-7239-49f8cbd33700/public","liquidity":0,"liquidityPrice":0,"volume":5714.65505342,"volume24h":0,"volumeNotional":11337.51010684,"volumeNotional24h":0,"users":28,"shares":112.2778377,"featured":true,"featuredAt":"2026-10-23T15:00:00.575Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x45B3cc9Ac174b2BceEE5E489BE3C6c2782890739","0x45ED65b3a3dEebA28d439D5Ce5dA9b99F781D89a","0xFfF753C33A0610Ed48c6f6ffbF84D7ec9E96Aa41"],"outcomes":[{"id":0,"title":"Yes","shares":56.13891885,"sharesHeld":56.13891885,"price":0.075,"closingPrice":null,"priceChange24h":0.15384615,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":3,"tokenId":"6442"},{"id":1,"title":"No","shares":56.13891885,"sharesHeld":56.13891885,"price":0.925,"closingPrice":null,"priceChange24h":-0.01069519,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":1,"tokenId":"6443"}],"eventId":"12f1348d-da61-45e8-95c3-8380c86b61eb","outcomeIndex":3,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"16fc43cb-e906-44f6-8066-8862cf52087b","providerName":"polymarket","externalMarketId":"2589813","externalMarketUrl":"https://polymarket.com/event/fed-decision-in-october-20260617190323537","externalMarketTitle":"Will the Fed increase interest rates by 25 bps after the October 2026 meeting?"}]},{"id":3218,"networkId":56,"slug":"feds-interest-rate-decision-in-october-will-the-fed-decrease","title":"Will the Fed decrease interest rates by 50+ bps after the October 2026 meeting?","shortName":"50+ bps decrease","ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period**: From the publication date through October 28, 2026, at 11:59 PM UTC.\n- **Market Close:** October 28, 2026, at 11:59 PM UTC.\n- **Resolution Deadline:** The resolution will be determined after the outcome is announced.\n\n### **Resolution Criteria:**\n\n- This market will resolve according to the amount of basis points by which the upper bound of the target federal funds rate changes relative to the level in place before the Federal Reserve’s October 2026 meeting. For example, if the FED decides to keep rates the same, this market will resolve “**Yes**” for “**No change**” and **“No”** for all other listed options.\n\n### **Resolution Details:**\n\n- This market will resolve based on the official FOMC statement released following its October 27-28, 2026 meeting. If no statement is released before the end date of the subsequent scheduled meeting, the market will resolve to “Yes” for the “No change” bracket.\n- If the target federal funds rate is adjusted to a level not represented by the listed options, the change will be rounded up to the nearest 25 basis points and resolved to the corresponding bracket. For example, a 12.5 bps increase or decrease will be treated as a 25 bps change.\n\n### **Cancellation Conditions:**\n\n- The resolution source is suspended, becomes unreliable, or experiences significant disruptions during the Market Period.\n\nIf cancelled, participants may claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on their open positions.","publishedAt":"2026-09-22T09:42:16.000Z","expiresAt":"2026-10-28T23:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":98,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,98,96,94,92,90,88,86,84,82,80,78,76,74,72,70,68,66,64,62,60,58,56,54,52,50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14,12,10,8,6,4,2,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm","resolutionTitle":"FED","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/sbr7Oovq0hYcm14hCQ0I9m3K786aozx9nbVzbNEAzaI/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ae98808d-f65c-4c67-7239-49f8cbd33700/public","liquidity":0,"liquidityPrice":0,"volume":0,"volume24h":0,"volumeNotional":0,"volumeNotional24h":0,"users":0,"shares":0,"featured":true,"featuredAt":"2026-10-23T15:00:00.575Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":[],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.01,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"6436"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.99,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"6437"}],"eventId":"12f1348d-da61-45e8-95c3-8380c86b61eb","outcomeIndex":0,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"4f1f7762-84e0-44b6-8975-afb0218204e6","providerName":"polymarket","externalMarketId":"2589810","externalMarketUrl":"https://polymarket.com/event/fed-decision-in-october-20260617190323537","externalMarketTitle":"Will the Fed decrease interest rates by 50+ bps after the October 2026 meeting?"}]},{"id":3219,"networkId":56,"slug":"feds-interest-rate-decision-in-october-will-the-fed-decrease-45c27bb9","title":"Will the Fed decrease interest rates by 25 bps after the October 2026 meeting?","shortName":"25 bps decrease","ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period**: From the publication date through October 28, 2026, at 11:59 PM UTC.\n- **Market Close:** October 28, 2026, at 11:59 PM UTC.\n- **Resolution Deadline:** The resolution will be determined after the outcome is announced.\n\n### **Resolution Criteria:**\n\n- This market will resolve according to the amount of basis points by which the upper bound of the target federal funds rate changes relative to the level in place before the Federal Reserve’s October 2026 meeting. For example, if the FED decides to keep rates the same, this market will resolve “**Yes**” for “**No change**” and **“No”** for all other listed options.\n\n### **Resolution Details:**\n\n- This market will resolve based on the official FOMC statement released following its October 27-28, 2026 meeting. If no statement is released before the end date of the subsequent scheduled meeting, the market will resolve to “Yes” for the “No change” bracket.\n- If the target federal funds rate is adjusted to a level not represented by the listed options, the change will be rounded up to the nearest 25 basis points and resolved to the corresponding bracket. For example, a 12.5 bps increase or decrease will be treated as a 25 bps change.\n\n### **Cancellation Conditions:**\n\n- The resolution source is suspended, becomes unreliable, or experiences significant disruptions during the Market Period.\n\nIf cancelled, participants may claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on their open positions.","publishedAt":"2026-09-22T09:42:24.000Z","expiresAt":"2026-10-28T23:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":98,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,98,96,94,92,90,88,86,84,82,80,78,76,74,72,70,68,66,64,62,60,58,56,54,52,50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14,12,10,8,6,4,2,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm","resolutionTitle":"FED","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/sbr7Oovq0hYcm14hCQ0I9m3K786aozx9nbVzbNEAzaI/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ae98808d-f65c-4c67-7239-49f8cbd33700/public","liquidity":0,"liquidityPrice":0,"volume":0,"volume24h":0,"volumeNotional":0,"volumeNotional24h":0,"users":0,"shares":0,"featured":true,"featuredAt":"2026-10-23T15:00:00.575Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":[],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.01,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"6438"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.99,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"6439"}],"eventId":"12f1348d-da61-45e8-95c3-8380c86b61eb","outcomeIndex":1,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"1aafd0aa-616b-42f0-8560-eba0f3100e47","providerName":"polymarket","externalMarketId":"2589811","externalMarketUrl":"https://polymarket.com/event/fed-decision-in-october-20260617190323537","externalMarketTitle":"Will the Fed decrease interest rates by 25 bps after the October 2026 meeting?"}]},{"id":3222,"networkId":56,"slug":"feds-interest-rate-decision-in-october-will-the-fed-increase-eb2c8b0e","title":"Will the Fed increase interest rates by 50+ bps after the October 2026 meeting?","shortName":"50+ bps increase","ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period**: From the publication date through October 28, 2026, at 11:59 PM UTC.\n- **Market Close:** October 28, 2026, at 11:59 PM UTC.\n- **Resolution Deadline:** The resolution will be determined after the outcome is announced.\n\n### **Resolution Criteria:**\n\n- This market will resolve according to the amount of basis points by which the upper bound of the target federal funds rate changes relative to the level in place before the Federal Reserve’s October 2026 meeting. For example, if the FED decides to keep rates the same, this market will resolve “**Yes**” for “**No change**” and **“No”** for all other listed options.\n\n### **Resolution Details:**\n\n- This market will resolve based on the official FOMC statement released following its October 27-28, 2026 meeting. If no statement is released before the end date of the subsequent scheduled meeting, the market will resolve to “Yes” for the “No change” bracket.\n- If the target federal funds rate is adjusted to a level not represented by the listed options, the change will be rounded up to the nearest 25 basis points and resolved to the corresponding bracket. For example, a 12.5 bps increase or decrease will be treated as a 25 bps change.\n\n### **Cancellation Conditions:**\n\n- The resolution source is suspended, becomes unreliable, or experiences significant disruptions during the Market Period.\n\nIf cancelled, participants may claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on their open positions.","publishedAt":"2026-09-22T09:42:49.000Z","expiresAt":"2026-10-28T23:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":98,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,98,96,94,92,90,88,86,84,82,80,78,76,74,72,70,68,66,64,62,60,58,56,54,52,50,48,46,44,42,40,38,36,34,32,30,28,26,24,22,20,18,16,14,12,10,8,6,4,2,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm","resolutionTitle":"FED","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/sbr7Oovq0hYcm14hCQ0I9m3K786aozx9nbVzbNEAzaI/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ae98808d-f65c-4c67-7239-49f8cbd33700/public","liquidity":0,"liquidityPrice":0,"volume":0,"volume24h":0,"volumeNotional":0,"volumeNotional24h":0,"users":0,"shares":0,"featured":true,"featuredAt":"2026-10-23T15:00:00.575Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":[],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.01,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"6444"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.99,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"6445"}],"eventId":"12f1348d-da61-45e8-95c3-8380c86b61eb","outcomeIndex":4,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"7c2ae1b7-a39c-4ea0-98ad-d2854eeb042e","providerName":"polymarket","externalMarketId":"2589814","externalMarketUrl":"https://polymarket.com/event/fed-decision-in-october-20260617190323537","externalMarketTitle":"Will the Fed increase interest rates by 50+ bps after the October 2026 meeting?"}]}]},{"type":"market","id":120546,"networkId":56,"slug":"eth-at-three-digits-when-bitcoin-goes-below-50k","title":"ETH at three digits when Bitcoin goes below 50K?","shortName":null,"ctaName":null,"description":"### **Market Details:**\n\n- **Market Close:** This market will only be closed once a resolution is achieved.\n- **Resolution Deadline:** The resolution will be determined as soon as an outcome is reached.\n- **Observation Period:** From the minute candle the Conditional BTC Target is hit on the resolution source until exactly 48 hours later.\n- **Resolution Time:** 48 hours after the Conditional BTC Target is met or before during the Observation Period if the ETH Market Target is reached.\n- **Conditional BTC Target:** $50,000.00\n- **ETH Market Target**: $999.99\n\n### **Resolution Criteria:**\n\n- The market resolves to “**Yes**” if ETH/USDT is at or below the ETH Market Target at any point during the Observation Period.\n- The market resolves to “**No**” if ETH/USD is above the ETH Market Target during the entire Observation Period.\n\n### **Resolution Details:**\n\nThe market resolves based on the Binance ETH/USDT and BTC/USDT spot price, using the 1-minute chart with the following specifications:\n\n- 1-minute timeframe (\"1m\").\n- Trading View.\n- \"Close\" price of each candle.\n\nOnly the price of ETH/USDT and BTC/USDT as quoted by Binance (spot market) will be considered.\n\n- The Observation Period will start at the exact 1-minute candle that the Conditional BTC Target was hit and last until exactly 48 hours later. For example, if the Conditional BTC Target is hit on March 31, 2026 at 11:32 PM UTC, all 1-minute candles until April 2, 2026 at 11:32 (including the last candle) will be subject to this markets resolution.\n- The ETH Market Target being hit outside of the Observation Period will not have any influence on the resolution of this market.\n\n### **Cancellation (Invalidity) Conditions:**\n\n- The BTC/USDT or ETH/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions.\n- Any circumstance prevents reliable price tracking.\n- Myriad Markets undergoes a change in its contract that demands a cancellation of all active markets, or a similar significant technical change.\n- Myriad Markets decides it is time for this market to cease to exist.\n\nIn the event of cancellation, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-03-11T16:02:42.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0.01,"treasury_fee":0,"distributor_fee":0.01},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[],"resolutionSource":"https://www.binance.com/en/trade/ETH_USDT?type=spot","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmQpfZBnU1V9bBQqQV86VqgWdk4kjWLQxTfnzWPL1UNsK4","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/c41e5029-9b30-488e-4cb4-dc15abfa0600/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/3a12d971-1cbc-46bf-d576-63dabcaf0000/public","liquidity":700,"liquidityPrice":0.88881251,"volume":2301.22337576,"volume24h":79.1821,"volumeNotional":4013.26815681,"volumeNotional24h":271.6275123,"users":41,"shares":1575.13534296,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x14d29370Ec944C505E9BCddce69B8E87BDBd314E","0x7836D709E750F6A2D6ff9362457028e8Cd901EB3","0xF7161633812d283e31e552757f62B51358c8Cc5E"],"outcomes":[{"id":0,"title":"Yes","shares":1148.48710453,"sharesHeld":417.44791187,"price":0.27086449,"closingPrice":null,"priceChange24h":0.00000366,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmQpfZBnU1V9bBQqQV86VqgWdk4kjWLQxTfnzWPL1UNsK4","holders":3,"tokenId":"241092"},{"id":1,"title":"No","shares":426.64823842,"sharesHeld":1139.28677798,"price":0.72913551,"closingPrice":null,"priceChange24h":-0.00000136,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":4,"tokenId":"241093"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"type":"event","id":"2257269d-e465-4fef-ac63-96f4242b930e","networkId":56,"slug":"btc-price-on-october-25-4pm-utc","title":"BTC price on October 25, 4PM UTC","description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","state":"open","resolvedOutcomeIndex":null,"expiresAt":"2026-10-25T16:00:00.000Z","publishedAt":"2026-10-19T10:03:31.062Z","negRisk":false,"negRiskId":null,"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"volume":289.76636964,"volume24h":43.57827537,"volumeNotional":551.2490518,"volumeNotional24h":88.5709889,"liquidity":2644.49946358,"users":16,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"externalSources":[{"id":"88b4c1ba-219d-45c3-ba1e-f9f852726373","providerName":"polymarket","externalMarketId":"1212409","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"Bitcoin above ___ on October 25?"}],"sortByProbability":false,"scoreboard":null,"markets":[{"id":3722,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-80000-on-october-2","title":"BTC above $80,000 on October 25, 4PM UTC?","shortName":"Above 80,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:13.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":818.31020858,"liquidityPrice":0,"volume":61.8740028,"volume24h":13.6705618,"volumeNotional":102.47097036,"volumeNotional24h":27.3411236,"users":3,"shares":32.34117036,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xd657025533626758DC5690DA581b28908df81955","0xfAaC195CaC01fD136A69f5640277A6B99DE3DD8F"],"outcomes":[{"id":0,"title":"Yes","shares":16.17058518,"sharesHeld":16.17058518,"price":0.93,"closingPrice":null,"priceChange24h":0.17721519,"bestBid":0.92,"bestAsk":0.94,"imageUrl":null,"holders":2,"tokenId":"7444"},{"id":1,"title":"No","shares":16.17058518,"sharesHeld":16.17058518,"price":0.07,"closingPrice":null,"priceChange24h":-0.66666667,"bestBid":0.06,"bestAsk":0.08,"imageUrl":null,"holders":1,"tokenId":"7445"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":0,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"80000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"ff95a6e8-d34f-434c-81b7-5b81cc124482","providerName":"polymarket","externalMarketId":"5889110","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $80,000 on October 25, 4PM UTC?"}]},{"id":3723,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-82000-on-october-2","title":"BTC above $82,000 on October 25, 4PM UTC?","shortName":"Above 82,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:22.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":1364.02227272,"liquidityPrice":0,"volume":100.96413963,"volume24h":15.20601865,"volumeNotional":204.78946926,"volumeNotional24h":37.4379653,"users":5,"shares":28.16356338,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xdc2F309C46fF654423EEd8413322d5dB06b94bEE","0x35318492254e1Ad3C642F2DEd2930856B17F9B27"],"outcomes":[{"id":0,"title":"Yes","shares":14.08178169,"sharesHeld":14.08178169,"price":0.395,"closingPrice":null,"priceChange24h":-0.03658537,"bestBid":0.38,"bestAsk":0.41,"imageUrl":null,"holders":2,"tokenId":"7446"},{"id":1,"title":"No","shares":14.08178169,"sharesHeld":14.08178169,"price":0.605,"closingPrice":null,"priceChange24h":0.02542373,"bestBid":0.59,"bestAsk":0.62,"imageUrl":null,"holders":1,"tokenId":"7447"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":1,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"82000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"781adbb4-590b-4582-99ff-f6d006128b2c","providerName":"polymarket","externalMarketId":"5889111","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $82,000 on October 25, 4PM UTC?"}]},{"id":3724,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-84000-on-october-2","title":"BTC above $84,000 on October 25, 4PM UTC?","shortName":"Above 84,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:30.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":154.64075856,"liquidityPrice":0,"volume":123.38143651,"volume24h":14.70169492,"volumeNotional":236.89503078,"volumeNotional24h":23.7919,"users":4,"shares":100.92929834,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xcE3a0442A230F3b1DBc495d97ae904b835712318"],"outcomes":[{"id":0,"title":"Yes","shares":50.46464917,"sharesHeld":50.46464917,"price":0.015,"closingPrice":null,"priceChange24h":-0.85714286,"bestBid":null,"bestAsk":0.03,"imageUrl":null,"holders":1,"tokenId":"7448"},{"id":1,"title":"No","shares":50.46464917,"sharesHeld":50.46464917,"price":0.985,"closingPrice":null,"priceChange24h":0.10055866,"bestBid":0.97,"bestAsk":null,"imageUrl":null,"holders":1,"tokenId":"7449"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":2,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"84000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"6dca79b7-4043-4fd2-bb8a-63e03537e841","providerName":"polymarket","externalMarketId":"5889113","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $84,000 on October 25, 4PM UTC?"}]},{"id":3725,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-86000-on-october-2","title":"BTC above $86,000 on October 25, 4PM UTC?","shortName":"Above 86,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:39.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":153.76311186,"liquidityPrice":0,"volume":3.5467907,"volume24h":0,"volumeNotional":7.0935814,"volumeNotional24h":0,"users":4,"shares":7.0935814,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xb1d7D5E97Bd7eF18d11137D433b15E42369Dc83f","0xc9e933f80dA9942cA101ae6131287852e60aFd00"],"outcomes":[{"id":0,"title":"Yes","shares":3.5467907,"sharesHeld":3.5467907,"price":0.01,"closingPrice":null,"priceChange24h":-0.33333333,"bestBid":null,"bestAsk":0.02,"imageUrl":null,"holders":1,"tokenId":"7450"},{"id":1,"title":"No","shares":3.5467907,"sharesHeld":3.5467907,"price":0.99,"closingPrice":null,"priceChange24h":0.00507614,"bestBid":0.98,"bestAsk":null,"imageUrl":null,"holders":3,"tokenId":"7451"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":3,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"86000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"b66f6646-e2c5-46bb-987a-2d73a35c64ae","providerName":"polymarket","externalMarketId":"5889115","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $86,000 on October 25, 4PM UTC?"}]},{"id":3726,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-88000-on-october-2","title":"BTC above $88,000 on October 25, 4PM UTC?","shortName":"Above 88,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:47.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":153.76311186,"liquidityPrice":0,"volume":0,"volume24h":0,"volumeNotional":0,"volumeNotional24h":0,"users":0,"shares":0,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":[],"outcomes":[{"id":0,"title":"Yes","shares":0,"sharesHeld":0,"price":0.01,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":0.02,"imageUrl":null,"holders":0,"tokenId":"7452"},{"id":1,"title":"No","shares":0,"sharesHeld":0,"price":0.99,"closingPrice":null,"priceChange24h":0,"bestBid":0.98,"bestAsk":null,"imageUrl":null,"holders":0,"tokenId":"7453"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":4,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"88000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"6f8dd854-ce69-4fb0-9fa7-ff8d53bc52a3","providerName":"polymarket","externalMarketId":"5889117","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $88,000 on October 25, 4PM UTC?"}]}]}],"pagination":{"page":1,"limit":10,"total":78,"totalPages":8,"hasNext":true,"hasPrev":false}} \ No newline at end of file diff --git a/tests/samples/myriad_market.json b/tests/samples/myriad_market.json new file mode 100644 index 0000000..1833aff --- /dev/null +++ b/tests/samples/myriad_market.json @@ -0,0 +1 @@ +{"id":3722,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-80000-on-october-2","title":"BTC above $80,000 on October 25, 4PM UTC?","shortName":"Above 80,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:13.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://cdn.polkamarkets.com/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":1092.43725892,"liquidityPrice":0,"volume":61.8740028,"volume24h":13.6705618,"volumeNotional":102.47097036,"volumeNotional24h":27.3411236,"users":3,"shares":32.34117036,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xd657025533626758DC5690DA581b28908df81955","0xfAaC195CaC01fD136A69f5640277A6B99DE3DD8F"],"outcomes":[{"id":0,"title":"Yes","shares":16.17058518,"sharesHeld":16.17058518,"price":0.91,"closingPrice":null,"priceChange24h":0.12345679,"bestBid":0.89,"bestAsk":0.93,"imageUrl":null,"holders":2,"tokenId":"7444","price_charts":[{"timeframe":"24h","prices":[{"value":0.81,"timestamp":1792686300,"date":"2026-10-22T16:25:00.000Z"},{"value":0.79,"timestamp":1792686600,"date":"2026-10-22T16:30:00.000Z"},{"value":0.79,"timestamp":1792686900,"date":"2026-10-22T16:35:00.000Z"},{"value":0.78,"timestamp":1792687200,"date":"2026-10-22T16:40:00.000Z"},{"value":0.73,"timestamp":1792687500,"date":"2026-10-22T16:45:00.000Z"},{"value":0.71,"timestamp":1792687800,"date":"2026-10-22T16:50:00.000Z"},{"value":0.75,"timestamp":1792688100,"date":"2026-10-22T16:55:00.000Z"},{"value":0.75,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.75,"timestamp":1792688700,"date":"2026-10-22T17:05:00.000Z"},{"value":0.74,"timestamp":1792689000,"date":"2026-10-22T17:10:00.000Z"},{"value":0.76,"timestamp":1792689300,"date":"2026-10-22T17:15:00.000Z"},{"value":0.77,"timestamp":1792689600,"date":"2026-10-22T17:20:00.000Z"},{"value":0.76,"timestamp":1792689900,"date":"2026-10-22T17:25:00.000Z"},{"value":0.76,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.76,"timestamp":1792690500,"date":"2026-10-22T17:35:00.000Z"},{"value":0.76,"timestamp":1792690800,"date":"2026-10-22T17:40:00.000Z"},{"value":0.76,"timestamp":1792691100,"date":"2026-10-22T17:45:00.000Z"},{"value":0.76,"timestamp":1792691400,"date":"2026-10-22T17:50:00.000Z"},{"value":0.75,"timestamp":1792691700,"date":"2026-10-22T17:55:00.000Z"},{"value":0.72,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.73,"timestamp":1792692300,"date":"2026-10-22T18:05:00.000Z"},{"value":0.73,"timestamp":1792692600,"date":"2026-10-22T18:10:00.000Z"},{"value":0.73,"timestamp":1792692900,"date":"2026-10-22T18:15:00.000Z"},{"value":0.75,"timestamp":1792693200,"date":"2026-10-22T18:20:00.000Z"},{"value":0.74,"timestamp":1792693500,"date":"2026-10-22T18:25:00.000Z"},{"value":0.74,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.75,"timestamp":1792694100,"date":"2026-10-22T18:35:00.000Z"},{"value":0.76,"timestamp":1792694400,"date":"2026-10-22T18:40:00.000Z"},{"value":0.775,"timestamp":1792694700,"date":"2026-10-22T18:45:00.000Z"},{"value":0.77,"timestamp":1792695000,"date":"2026-10-22T18:50:00.000Z"},{"value":0.77,"timestamp":1792695300,"date":"2026-10-22T18:55:00.000Z"},{"value":0.78,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.77,"timestamp":1792695900,"date":"2026-10-22T19:05:00.000Z"},{"value":0.77,"timestamp":1792696200,"date":"2026-10-22T19:10:00.000Z"},{"value":0.78,"timestamp":1792696500,"date":"2026-10-22T19:15:00.000Z"},{"value":0.79,"timestamp":1792696800,"date":"2026-10-22T19:20:00.000Z"},{"value":0.79,"timestamp":1792697100,"date":"2026-10-22T19:25:00.000Z"},{"value":0.8,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.79,"timestamp":1792697700,"date":"2026-10-22T19:35:00.000Z"},{"value":0.78,"timestamp":1792698000,"date":"2026-10-22T19:40:00.000Z"},{"value":0.78,"timestamp":1792698300,"date":"2026-10-22T19:45:00.000Z"},{"value":0.8,"timestamp":1792698600,"date":"2026-10-22T19:50:00.000Z"},{"value":0.79,"timestamp":1792698900,"date":"2026-10-22T19:55:00.000Z"},{"value":0.79,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.79,"timestamp":1792699500,"date":"2026-10-22T20:05:00.000Z"},{"value":0.79,"timestamp":1792699800,"date":"2026-10-22T20:10:00.000Z"},{"value":0.78,"timestamp":1792700100,"date":"2026-10-22T20:15:00.000Z"},{"value":0.78,"timestamp":1792700400,"date":"2026-10-22T20:20:00.000Z"},{"value":0.78,"timestamp":1792700700,"date":"2026-10-22T20:25:00.000Z"},{"value":0.78,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.78,"timestamp":1792701300,"date":"2026-10-22T20:35:00.000Z"},{"value":0.77,"timestamp":1792701600,"date":"2026-10-22T20:40:00.000Z"},{"value":0.76,"timestamp":1792701900,"date":"2026-10-22T20:45:00.000Z"},{"value":0.76,"timestamp":1792702200,"date":"2026-10-22T20:50:00.000Z"},{"value":0.77,"timestamp":1792702500,"date":"2026-10-22T20:55:00.000Z"},{"value":0.76,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.79,"timestamp":1792703100,"date":"2026-10-22T21:05:00.000Z"},{"value":0.79,"timestamp":1792703400,"date":"2026-10-22T21:10:00.000Z"},{"value":0.81,"timestamp":1792703700,"date":"2026-10-22T21:15:00.000Z"},{"value":0.81,"timestamp":1792704000,"date":"2026-10-22T21:20:00.000Z"},{"value":0.81,"timestamp":1792704300,"date":"2026-10-22T21:25:00.000Z"},{"value":0.8,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.81,"timestamp":1792704900,"date":"2026-10-22T21:35:00.000Z"},{"value":0.81,"timestamp":1792705200,"date":"2026-10-22T21:40:00.000Z"},{"value":0.81,"timestamp":1792705500,"date":"2026-10-22T21:45:00.000Z"},{"value":0.81,"timestamp":1792705800,"date":"2026-10-22T21:50:00.000Z"},{"value":0.81,"timestamp":1792706100,"date":"2026-10-22T21:55:00.000Z"},{"value":0.81,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.81,"timestamp":1792706700,"date":"2026-10-22T22:05:00.000Z"},{"value":0.81,"timestamp":1792707000,"date":"2026-10-22T22:10:00.000Z"},{"value":0.79,"timestamp":1792707300,"date":"2026-10-22T22:15:00.000Z"},{"value":0.79,"timestamp":1792707600,"date":"2026-10-22T22:20:00.000Z"},{"value":0.79,"timestamp":1792707900,"date":"2026-10-22T22:25:00.000Z"},{"value":0.79,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.8,"timestamp":1792708500,"date":"2026-10-22T22:35:00.000Z"},{"value":0.78,"timestamp":1792708800,"date":"2026-10-22T22:40:00.000Z"},{"value":0.78,"timestamp":1792709100,"date":"2026-10-22T22:45:00.000Z"},{"value":0.79,"timestamp":1792709400,"date":"2026-10-22T22:50:00.000Z"},{"value":0.79,"timestamp":1792709700,"date":"2026-10-22T22:55:00.000Z"},{"value":0.79,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.79,"timestamp":1792710300,"date":"2026-10-22T23:05:00.000Z"},{"value":0.79,"timestamp":1792710600,"date":"2026-10-22T23:10:00.000Z"},{"value":0.79,"timestamp":1792710900,"date":"2026-10-22T23:15:00.000Z"},{"value":0.79,"timestamp":1792711200,"date":"2026-10-22T23:20:00.000Z"},{"value":0.79,"timestamp":1792711500,"date":"2026-10-22T23:25:00.000Z"},{"value":0.79,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.79,"timestamp":1792712100,"date":"2026-10-22T23:35:00.000Z"},{"value":0.79,"timestamp":1792712400,"date":"2026-10-22T23:40:00.000Z"},{"value":0.8,"timestamp":1792712700,"date":"2026-10-22T23:45:00.000Z"},{"value":0.8,"timestamp":1792713000,"date":"2026-10-22T23:50:00.000Z"},{"value":0.81,"timestamp":1792713300,"date":"2026-10-22T23:55:00.000Z"},{"value":0.78,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.78,"timestamp":1792713900,"date":"2026-10-23T00:05:00.000Z"},{"value":0.77,"timestamp":1792714200,"date":"2026-10-23T00:10:00.000Z"},{"value":0.77,"timestamp":1792714500,"date":"2026-10-23T00:15:00.000Z"},{"value":0.76,"timestamp":1792714800,"date":"2026-10-23T00:20:00.000Z"},{"value":0.76,"timestamp":1792715100,"date":"2026-10-23T00:25:00.000Z"},{"value":0.78,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.78,"timestamp":1792715700,"date":"2026-10-23T00:35:00.000Z"},{"value":0.8,"timestamp":1792716000,"date":"2026-10-23T00:40:00.000Z"},{"value":0.81,"timestamp":1792716300,"date":"2026-10-23T00:45:00.000Z"},{"value":0.81,"timestamp":1792716600,"date":"2026-10-23T00:50:00.000Z"},{"value":0.81,"timestamp":1792716900,"date":"2026-10-23T00:55:00.000Z"},{"value":0.8,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.79,"timestamp":1792717500,"date":"2026-10-23T01:05:00.000Z"},{"value":0.8,"timestamp":1792717800,"date":"2026-10-23T01:10:00.000Z"},{"value":0.79,"timestamp":1792718100,"date":"2026-10-23T01:15:00.000Z"},{"value":0.8,"timestamp":1792718400,"date":"2026-10-23T01:20:00.000Z"},{"value":0.81,"timestamp":1792718700,"date":"2026-10-23T01:25:00.000Z"},{"value":0.8,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.77,"timestamp":1792719300,"date":"2026-10-23T01:35:00.000Z"},{"value":0.8,"timestamp":1792719600,"date":"2026-10-23T01:40:00.000Z"},{"value":0.84,"timestamp":1792719900,"date":"2026-10-23T01:45:00.000Z"},{"value":0.83,"timestamp":1792720200,"date":"2026-10-23T01:50:00.000Z"},{"value":0.805,"timestamp":1792720500,"date":"2026-10-23T01:55:00.000Z"},{"value":0.78,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.78,"timestamp":1792721100,"date":"2026-10-23T02:05:00.000Z"},{"value":0.8,"timestamp":1792721400,"date":"2026-10-23T02:10:00.000Z"},{"value":0.8,"timestamp":1792721700,"date":"2026-10-23T02:15:00.000Z"},{"value":0.82,"timestamp":1792722000,"date":"2026-10-23T02:20:00.000Z"},{"value":0.83,"timestamp":1792722300,"date":"2026-10-23T02:25:00.000Z"},{"value":0.87,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.87,"timestamp":1792722900,"date":"2026-10-23T02:35:00.000Z"},{"value":0.87,"timestamp":1792723200,"date":"2026-10-23T02:40:00.000Z"},{"value":0.87,"timestamp":1792723500,"date":"2026-10-23T02:45:00.000Z"},{"value":0.88,"timestamp":1792723800,"date":"2026-10-23T02:50:00.000Z"},{"value":0.88,"timestamp":1792724100,"date":"2026-10-23T02:55:00.000Z"},{"value":0.88,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.88,"timestamp":1792724700,"date":"2026-10-23T03:05:00.000Z"},{"value":0.88,"timestamp":1792725000,"date":"2026-10-23T03:10:00.000Z"},{"value":0.88,"timestamp":1792725300,"date":"2026-10-23T03:15:00.000Z"},{"value":0.88,"timestamp":1792725600,"date":"2026-10-23T03:20:00.000Z"},{"value":0.88,"timestamp":1792725900,"date":"2026-10-23T03:25:00.000Z"},{"value":0.88,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.88,"timestamp":1792726500,"date":"2026-10-23T03:35:00.000Z"},{"value":0.88,"timestamp":1792726800,"date":"2026-10-23T03:40:00.000Z"},{"value":0.89,"timestamp":1792727100,"date":"2026-10-23T03:45:00.000Z"},{"value":0.9,"timestamp":1792727400,"date":"2026-10-23T03:50:00.000Z"},{"value":0.9,"timestamp":1792727700,"date":"2026-10-23T03:55:00.000Z"},{"value":0.9,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.87,"timestamp":1792728300,"date":"2026-10-23T04:05:00.000Z"},{"value":0.87,"timestamp":1792728600,"date":"2026-10-23T04:10:00.000Z"},{"value":0.87,"timestamp":1792728900,"date":"2026-10-23T04:15:00.000Z"},{"value":0.87,"timestamp":1792729200,"date":"2026-10-23T04:20:00.000Z"},{"value":0.85,"timestamp":1792729500,"date":"2026-10-23T04:25:00.000Z"},{"value":0.85,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.86,"timestamp":1792730100,"date":"2026-10-23T04:35:00.000Z"},{"value":0.86,"timestamp":1792730400,"date":"2026-10-23T04:40:00.000Z"},{"value":0.86,"timestamp":1792730700,"date":"2026-10-23T04:45:00.000Z"},{"value":0.855,"timestamp":1792731000,"date":"2026-10-23T04:50:00.000Z"},{"value":0.84,"timestamp":1792731300,"date":"2026-10-23T04:55:00.000Z"},{"value":0.85,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.86,"timestamp":1792731900,"date":"2026-10-23T05:05:00.000Z"},{"value":0.87,"timestamp":1792732200,"date":"2026-10-23T05:10:00.000Z"},{"value":0.87,"timestamp":1792732500,"date":"2026-10-23T05:15:00.000Z"},{"value":0.86,"timestamp":1792732800,"date":"2026-10-23T05:20:00.000Z"},{"value":0.855,"timestamp":1792733100,"date":"2026-10-23T05:25:00.000Z"},{"value":0.86,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.86,"timestamp":1792733700,"date":"2026-10-23T05:35:00.000Z"},{"value":0.86,"timestamp":1792734000,"date":"2026-10-23T05:40:00.000Z"},{"value":0.87,"timestamp":1792734300,"date":"2026-10-23T05:45:00.000Z"},{"value":0.87,"timestamp":1792734600,"date":"2026-10-23T05:50:00.000Z"},{"value":0.875,"timestamp":1792734900,"date":"2026-10-23T05:55:00.000Z"},{"value":0.87,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.87,"timestamp":1792735500,"date":"2026-10-23T06:05:00.000Z"},{"value":0.87,"timestamp":1792735800,"date":"2026-10-23T06:10:00.000Z"},{"value":0.88,"timestamp":1792736100,"date":"2026-10-23T06:15:00.000Z"},{"value":0.88,"timestamp":1792736400,"date":"2026-10-23T06:20:00.000Z"},{"value":0.88,"timestamp":1792736700,"date":"2026-10-23T06:25:00.000Z"},{"value":0.88,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.88,"timestamp":1792737300,"date":"2026-10-23T06:35:00.000Z"},{"value":0.88,"timestamp":1792737600,"date":"2026-10-23T06:40:00.000Z"},{"value":0.88,"timestamp":1792737900,"date":"2026-10-23T06:45:00.000Z"},{"value":0.88,"timestamp":1792738200,"date":"2026-10-23T06:50:00.000Z"},{"value":0.88,"timestamp":1792738500,"date":"2026-10-23T06:55:00.000Z"},{"value":0.875,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.88,"timestamp":1792739100,"date":"2026-10-23T07:05:00.000Z"},{"value":0.87,"timestamp":1792739400,"date":"2026-10-23T07:10:00.000Z"},{"value":0.87,"timestamp":1792739700,"date":"2026-10-23T07:15:00.000Z"},{"value":0.87,"timestamp":1792740000,"date":"2026-10-23T07:20:00.000Z"},{"value":0.87,"timestamp":1792740300,"date":"2026-10-23T07:25:00.000Z"},{"value":0.87,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.875,"timestamp":1792740900,"date":"2026-10-23T07:35:00.000Z"},{"value":0.86,"timestamp":1792741200,"date":"2026-10-23T07:40:00.000Z"},{"value":0.85,"timestamp":1792741500,"date":"2026-10-23T07:45:00.000Z"},{"value":0.84,"timestamp":1792741800,"date":"2026-10-23T07:50:00.000Z"},{"value":0.84,"timestamp":1792742100,"date":"2026-10-23T07:55:00.000Z"},{"value":0.84,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.85,"timestamp":1792742700,"date":"2026-10-23T08:05:00.000Z"},{"value":0.86,"timestamp":1792743000,"date":"2026-10-23T08:10:00.000Z"},{"value":0.88,"timestamp":1792743300,"date":"2026-10-23T08:15:00.000Z"},{"value":0.88,"timestamp":1792743600,"date":"2026-10-23T08:20:00.000Z"},{"value":0.88,"timestamp":1792743900,"date":"2026-10-23T08:25:00.000Z"},{"value":0.88,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.88,"timestamp":1792744500,"date":"2026-10-23T08:35:00.000Z"},{"value":0.89,"timestamp":1792744800,"date":"2026-10-23T08:40:00.000Z"},{"value":0.88,"timestamp":1792745100,"date":"2026-10-23T08:45:00.000Z"},{"value":0.88,"timestamp":1792745400,"date":"2026-10-23T08:50:00.000Z"},{"value":0.88,"timestamp":1792745700,"date":"2026-10-23T08:55:00.000Z"},{"value":0.88,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.88,"timestamp":1792746300,"date":"2026-10-23T09:05:00.000Z"},{"value":0.88,"timestamp":1792746600,"date":"2026-10-23T09:10:00.000Z"},{"value":0.88,"timestamp":1792746900,"date":"2026-10-23T09:15:00.000Z"},{"value":0.88,"timestamp":1792747200,"date":"2026-10-23T09:20:00.000Z"},{"value":0.87,"timestamp":1792747500,"date":"2026-10-23T09:25:00.000Z"},{"value":0.86,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.85,"timestamp":1792748100,"date":"2026-10-23T09:35:00.000Z"},{"value":0.86,"timestamp":1792748400,"date":"2026-10-23T09:40:00.000Z"},{"value":0.86,"timestamp":1792748700,"date":"2026-10-23T09:45:00.000Z"},{"value":0.87,"timestamp":1792749000,"date":"2026-10-23T09:50:00.000Z"},{"value":0.87,"timestamp":1792749300,"date":"2026-10-23T09:55:00.000Z"},{"value":0.87,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.88,"timestamp":1792749900,"date":"2026-10-23T10:05:00.000Z"},{"value":0.88,"timestamp":1792750200,"date":"2026-10-23T10:10:00.000Z"},{"value":0.89,"timestamp":1792750500,"date":"2026-10-23T10:15:00.000Z"},{"value":0.89,"timestamp":1792750800,"date":"2026-10-23T10:20:00.000Z"},{"value":0.88,"timestamp":1792751100,"date":"2026-10-23T10:25:00.000Z"},{"value":0.88,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.88,"timestamp":1792751700,"date":"2026-10-23T10:35:00.000Z"},{"value":0.88,"timestamp":1792752000,"date":"2026-10-23T10:40:00.000Z"},{"value":0.88,"timestamp":1792752300,"date":"2026-10-23T10:45:00.000Z"},{"value":0.88,"timestamp":1792752600,"date":"2026-10-23T10:50:00.000Z"},{"value":0.88,"timestamp":1792752900,"date":"2026-10-23T10:55:00.000Z"},{"value":0.87,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.87,"timestamp":1792753500,"date":"2026-10-23T11:05:00.000Z"},{"value":0.87,"timestamp":1792753800,"date":"2026-10-23T11:10:00.000Z"},{"value":0.87,"timestamp":1792754100,"date":"2026-10-23T11:15:00.000Z"},{"value":0.87,"timestamp":1792754400,"date":"2026-10-23T11:20:00.000Z"},{"value":0.88,"timestamp":1792754700,"date":"2026-10-23T11:25:00.000Z"},{"value":0.88,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.88,"timestamp":1792755300,"date":"2026-10-23T11:35:00.000Z"},{"value":0.89,"timestamp":1792755600,"date":"2026-10-23T11:40:00.000Z"},{"value":0.89,"timestamp":1792755900,"date":"2026-10-23T11:45:00.000Z"},{"value":0.89,"timestamp":1792756200,"date":"2026-10-23T11:50:00.000Z"},{"value":0.89,"timestamp":1792756500,"date":"2026-10-23T11:55:00.000Z"},{"value":0.88,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.88,"timestamp":1792757100,"date":"2026-10-23T12:05:00.000Z"},{"value":0.87,"timestamp":1792757400,"date":"2026-10-23T12:10:00.000Z"},{"value":0.89,"timestamp":1792757700,"date":"2026-10-23T12:15:00.000Z"},{"value":0.88,"timestamp":1792758000,"date":"2026-10-23T12:20:00.000Z"},{"value":0.89,"timestamp":1792758300,"date":"2026-10-23T12:25:00.000Z"},{"value":0.9,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.9,"timestamp":1792758900,"date":"2026-10-23T12:35:00.000Z"},{"value":0.9,"timestamp":1792759200,"date":"2026-10-23T12:40:00.000Z"},{"value":0.89,"timestamp":1792759500,"date":"2026-10-23T12:45:00.000Z"},{"value":0.89,"timestamp":1792759800,"date":"2026-10-23T12:50:00.000Z"},{"value":0.89,"timestamp":1792760100,"date":"2026-10-23T12:55:00.000Z"},{"value":0.9,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.89,"timestamp":1792760700,"date":"2026-10-23T13:05:00.000Z"},{"value":0.89,"timestamp":1792761000,"date":"2026-10-23T13:10:00.000Z"},{"value":0.885,"timestamp":1792761300,"date":"2026-10-23T13:15:00.000Z"},{"value":0.91,"timestamp":1792761600,"date":"2026-10-23T13:20:00.000Z"},{"value":0.89,"timestamp":1792761900,"date":"2026-10-23T13:25:00.000Z"},{"value":0.89,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.89,"timestamp":1792762500,"date":"2026-10-23T13:35:00.000Z"},{"value":0.91,"timestamp":1792762800,"date":"2026-10-23T13:40:00.000Z"},{"value":0.91,"timestamp":1792763100,"date":"2026-10-23T13:45:00.000Z"},{"value":0.91,"timestamp":1792763400,"date":"2026-10-23T13:50:00.000Z"},{"value":0.91,"timestamp":1792763700,"date":"2026-10-23T13:55:00.000Z"},{"value":0.91,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.9,"timestamp":1792764300,"date":"2026-10-23T14:05:00.000Z"},{"value":0.87,"timestamp":1792764600,"date":"2026-10-23T14:10:00.000Z"},{"value":0.8,"timestamp":1792764900,"date":"2026-10-23T14:15:00.000Z"},{"value":0.83,"timestamp":1792765200,"date":"2026-10-23T14:20:00.000Z"},{"value":0.84,"timestamp":1792765500,"date":"2026-10-23T14:25:00.000Z"},{"value":0.85,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.9,"timestamp":1792766100,"date":"2026-10-23T14:35:00.000Z"},{"value":0.9,"timestamp":1792766400,"date":"2026-10-23T14:40:00.000Z"},{"value":0.9,"timestamp":1792766700,"date":"2026-10-23T14:45:00.000Z"},{"value":0.92,"timestamp":1792767000,"date":"2026-10-23T14:50:00.000Z"},{"value":0.91,"timestamp":1792767300,"date":"2026-10-23T14:55:00.000Z"},{"value":0.89,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.89,"timestamp":1792767900,"date":"2026-10-23T15:05:00.000Z"},{"value":0.88,"timestamp":1792768200,"date":"2026-10-23T15:10:00.000Z"},{"value":0.88,"timestamp":1792768500,"date":"2026-10-23T15:15:00.000Z"},{"value":0.87,"timestamp":1792768800,"date":"2026-10-23T15:20:00.000Z"},{"value":0.88,"timestamp":1792769100,"date":"2026-10-23T15:25:00.000Z"},{"value":0.89,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"},{"value":0.88,"timestamp":1792769700,"date":"2026-10-23T15:35:00.000Z"},{"value":0.88,"timestamp":1792770000,"date":"2026-10-23T15:40:00.000Z"},{"value":0.89,"timestamp":1792770300,"date":"2026-10-23T15:45:00.000Z"},{"value":0.9,"timestamp":1792770600,"date":"2026-10-23T15:50:00.000Z"},{"value":0.9,"timestamp":1792770900,"date":"2026-10-23T15:55:00.000Z"},{"value":0.91,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"},{"value":0.92,"timestamp":1792771500,"date":"2026-10-23T16:05:00.000Z"},{"value":0.91,"timestamp":1792771800,"date":"2026-10-23T16:10:00.000Z"},{"value":0.91,"timestamp":1792772100,"date":"2026-10-23T16:15:00.000Z"},{"value":0.91,"timestamp":1792772400,"date":"2026-10-23T16:20:00.000Z"}],"change_percent":0.12345679012345676},{"timeframe":"7d","prices":[{"value":0.765,"timestamp":1792404000,"date":"2026-10-19T10:00:00.000Z"},{"value":0.77,"timestamp":1792405800,"date":"2026-10-19T10:30:00.000Z"},{"value":0.755,"timestamp":1792407600,"date":"2026-10-19T11:00:00.000Z"},{"value":0.76,"timestamp":1792409400,"date":"2026-10-19T11:30:00.000Z"},{"value":0.775,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.78,"timestamp":1792413000,"date":"2026-10-19T12:30:00.000Z"},{"value":0.785,"timestamp":1792414800,"date":"2026-10-19T13:00:00.000Z"},{"value":0.755,"timestamp":1792416600,"date":"2026-10-19T13:30:00.000Z"},{"value":0.745,"timestamp":1792418400,"date":"2026-10-19T14:00:00.000Z"},{"value":0.76,"timestamp":1792420200,"date":"2026-10-19T14:30:00.000Z"},{"value":0.78,"timestamp":1792422000,"date":"2026-10-19T15:00:00.000Z"},{"value":0.74,"timestamp":1792423800,"date":"2026-10-19T15:30:00.000Z"},{"value":0.75,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.745,"timestamp":1792427400,"date":"2026-10-19T16:30:00.000Z"},{"value":0.745,"timestamp":1792429200,"date":"2026-10-19T17:00:00.000Z"},{"value":0.735,"timestamp":1792431000,"date":"2026-10-19T17:30:00.000Z"},{"value":0.74,"timestamp":1792432800,"date":"2026-10-19T18:00:00.000Z"},{"value":0.745,"timestamp":1792434600,"date":"2026-10-19T18:30:00.000Z"},{"value":0.73,"timestamp":1792436400,"date":"2026-10-19T19:00:00.000Z"},{"value":0.725,"timestamp":1792438200,"date":"2026-10-19T19:30:00.000Z"},{"value":0.755,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.75,"timestamp":1792441800,"date":"2026-10-19T20:30:00.000Z"},{"value":0.76,"timestamp":1792443600,"date":"2026-10-19T21:00:00.000Z"},{"value":0.755,"timestamp":1792445400,"date":"2026-10-19T21:30:00.000Z"},{"value":0.765,"timestamp":1792447200,"date":"2026-10-19T22:00:00.000Z"},{"value":0.76,"timestamp":1792449000,"date":"2026-10-19T22:30:00.000Z"},{"value":0.745,"timestamp":1792450800,"date":"2026-10-19T23:00:00.000Z"},{"value":0.75,"timestamp":1792452600,"date":"2026-10-19T23:30:00.000Z"},{"value":0.705,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.7,"timestamp":1792456200,"date":"2026-10-20T00:30:00.000Z"},{"value":0.665,"timestamp":1792458000,"date":"2026-10-20T01:00:00.000Z"},{"value":0.72,"timestamp":1792459800,"date":"2026-10-20T01:30:00.000Z"},{"value":0.705,"timestamp":1792461600,"date":"2026-10-20T02:00:00.000Z"},{"value":0.725,"timestamp":1792463400,"date":"2026-10-20T02:30:00.000Z"},{"value":0.725,"timestamp":1792465200,"date":"2026-10-20T03:00:00.000Z"},{"value":0.755,"timestamp":1792467000,"date":"2026-10-20T03:30:00.000Z"},{"value":0.765,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.765,"timestamp":1792470600,"date":"2026-10-20T04:30:00.000Z"},{"value":0.765,"timestamp":1792472400,"date":"2026-10-20T05:00:00.000Z"},{"value":0.735,"timestamp":1792474200,"date":"2026-10-20T05:30:00.000Z"},{"value":0.725,"timestamp":1792476000,"date":"2026-10-20T06:00:00.000Z"},{"value":0.74,"timestamp":1792477800,"date":"2026-10-20T06:30:00.000Z"},{"value":0.73,"timestamp":1792479600,"date":"2026-10-20T07:00:00.000Z"},{"value":0.735,"timestamp":1792481400,"date":"2026-10-20T07:30:00.000Z"},{"value":0.71,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.705,"timestamp":1792485000,"date":"2026-10-20T08:30:00.000Z"},{"value":0.705,"timestamp":1792486800,"date":"2026-10-20T09:00:00.000Z"},{"value":0.71,"timestamp":1792488600,"date":"2026-10-20T09:30:00.000Z"},{"value":0.725,"timestamp":1792490400,"date":"2026-10-20T10:00:00.000Z"},{"value":0.725,"timestamp":1792492200,"date":"2026-10-20T10:30:00.000Z"},{"value":0.72,"timestamp":1792494000,"date":"2026-10-20T11:00:00.000Z"},{"value":0.72,"timestamp":1792495800,"date":"2026-10-20T11:30:00.000Z"},{"value":0.73,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.73,"timestamp":1792499400,"date":"2026-10-20T12:30:00.000Z"},{"value":0.7,"timestamp":1792501200,"date":"2026-10-20T13:00:00.000Z"},{"value":0.795,"timestamp":1792503000,"date":"2026-10-20T13:30:00.000Z"},{"value":0.85,"timestamp":1792504800,"date":"2026-10-20T14:00:00.000Z"},{"value":0.88,"timestamp":1792506600,"date":"2026-10-20T14:30:00.000Z"},{"value":0.81,"timestamp":1792508400,"date":"2026-10-20T15:00:00.000Z"},{"value":0.825,"timestamp":1792510200,"date":"2026-10-20T15:30:00.000Z"},{"value":0.83,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.84,"timestamp":1792513800,"date":"2026-10-20T16:30:00.000Z"},{"value":0.845,"timestamp":1792515600,"date":"2026-10-20T17:00:00.000Z"},{"value":0.845,"timestamp":1792517400,"date":"2026-10-20T17:30:00.000Z"},{"value":0.835,"timestamp":1792519200,"date":"2026-10-20T18:00:00.000Z"},{"value":0.835,"timestamp":1792521000,"date":"2026-10-20T18:30:00.000Z"},{"value":0.83,"timestamp":1792522800,"date":"2026-10-20T19:00:00.000Z"},{"value":0.84,"timestamp":1792524600,"date":"2026-10-20T19:30:00.000Z"},{"value":0.84,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.845,"timestamp":1792528200,"date":"2026-10-20T20:30:00.000Z"},{"value":0.845,"timestamp":1792530000,"date":"2026-10-20T21:00:00.000Z"},{"value":0.865,"timestamp":1792531800,"date":"2026-10-20T21:30:00.000Z"},{"value":0.88,"timestamp":1792533600,"date":"2026-10-20T22:00:00.000Z"},{"value":0.88,"timestamp":1792535400,"date":"2026-10-20T22:30:00.000Z"},{"value":0.88,"timestamp":1792537200,"date":"2026-10-20T23:00:00.000Z"},{"value":0.88,"timestamp":1792539000,"date":"2026-10-20T23:30:00.000Z"},{"value":0.885,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.885,"timestamp":1792542600,"date":"2026-10-21T00:30:00.000Z"},{"value":0.875,"timestamp":1792544400,"date":"2026-10-21T01:00:00.000Z"},{"value":0.885,"timestamp":1792546200,"date":"2026-10-21T01:30:00.000Z"},{"value":0.87,"timestamp":1792548000,"date":"2026-10-21T02:00:00.000Z"},{"value":0.87,"timestamp":1792549800,"date":"2026-10-21T02:30:00.000Z"},{"value":0.87,"timestamp":1792551600,"date":"2026-10-21T03:00:00.000Z"},{"value":0.86,"timestamp":1792553400,"date":"2026-10-21T03:30:00.000Z"},{"value":0.865,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.865,"timestamp":1792557000,"date":"2026-10-21T04:30:00.000Z"},{"value":0.865,"timestamp":1792558800,"date":"2026-10-21T05:00:00.000Z"},{"value":0.85,"timestamp":1792560600,"date":"2026-10-21T05:30:00.000Z"},{"value":0.845,"timestamp":1792562400,"date":"2026-10-21T06:00:00.000Z"},{"value":0.855,"timestamp":1792564200,"date":"2026-10-21T06:30:00.000Z"},{"value":0.88,"timestamp":1792566000,"date":"2026-10-21T07:00:00.000Z"},{"value":0.895,"timestamp":1792567800,"date":"2026-10-21T07:30:00.000Z"},{"value":0.895,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.895,"timestamp":1792571400,"date":"2026-10-21T08:30:00.000Z"},{"value":0.92,"timestamp":1792573200,"date":"2026-10-21T09:00:00.000Z"},{"value":0.925,"timestamp":1792575000,"date":"2026-10-21T09:30:00.000Z"},{"value":0.92,"timestamp":1792576800,"date":"2026-10-21T10:00:00.000Z"},{"value":0.92,"timestamp":1792578600,"date":"2026-10-21T10:30:00.000Z"},{"value":0.925,"timestamp":1792580400,"date":"2026-10-21T11:00:00.000Z"},{"value":0.925,"timestamp":1792582200,"date":"2026-10-21T11:30:00.000Z"},{"value":0.925,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.925,"timestamp":1792585800,"date":"2026-10-21T12:30:00.000Z"},{"value":0.94,"timestamp":1792587600,"date":"2026-10-21T13:00:00.000Z"},{"value":0.94,"timestamp":1792589400,"date":"2026-10-21T13:30:00.000Z"},{"value":0.935,"timestamp":1792591200,"date":"2026-10-21T14:00:00.000Z"},{"value":0.95,"timestamp":1792593000,"date":"2026-10-21T14:30:00.000Z"},{"value":0.96,"timestamp":1792594800,"date":"2026-10-21T15:00:00.000Z"},{"value":0.96,"timestamp":1792596600,"date":"2026-10-21T15:30:00.000Z"},{"value":0.965,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.965,"timestamp":1792600200,"date":"2026-10-21T16:30:00.000Z"},{"value":0.96,"timestamp":1792602000,"date":"2026-10-21T17:00:00.000Z"},{"value":0.97,"timestamp":1792603800,"date":"2026-10-21T17:30:00.000Z"},{"value":0.97,"timestamp":1792605600,"date":"2026-10-21T18:00:00.000Z"},{"value":0.97,"timestamp":1792607400,"date":"2026-10-21T18:30:00.000Z"},{"value":0.965,"timestamp":1792609200,"date":"2026-10-21T19:00:00.000Z"},{"value":0.97,"timestamp":1792611000,"date":"2026-10-21T19:30:00.000Z"},{"value":0.97,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.965,"timestamp":1792614600,"date":"2026-10-21T20:30:00.000Z"},{"value":0.965,"timestamp":1792616400,"date":"2026-10-21T21:00:00.000Z"},{"value":0.965,"timestamp":1792618200,"date":"2026-10-21T21:30:00.000Z"},{"value":0.975,"timestamp":1792620000,"date":"2026-10-21T22:00:00.000Z"},{"value":0.975,"timestamp":1792621800,"date":"2026-10-21T22:30:00.000Z"},{"value":0.975,"timestamp":1792623600,"date":"2026-10-21T23:00:00.000Z"},{"value":0.96,"timestamp":1792625400,"date":"2026-10-21T23:30:00.000Z"},{"value":0.96,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.96,"timestamp":1792629000,"date":"2026-10-22T00:30:00.000Z"},{"value":0.96,"timestamp":1792630800,"date":"2026-10-22T01:00:00.000Z"},{"value":0.94,"timestamp":1792632600,"date":"2026-10-22T01:30:00.000Z"},{"value":0.9,"timestamp":1792634400,"date":"2026-10-22T02:00:00.000Z"},{"value":0.9,"timestamp":1792636200,"date":"2026-10-22T02:30:00.000Z"},{"value":0.9,"timestamp":1792638000,"date":"2026-10-22T03:00:00.000Z"},{"value":0.91,"timestamp":1792639800,"date":"2026-10-22T03:30:00.000Z"},{"value":0.91,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.92,"timestamp":1792643400,"date":"2026-10-22T04:30:00.000Z"},{"value":0.91,"timestamp":1792645200,"date":"2026-10-22T05:00:00.000Z"},{"value":0.91,"timestamp":1792647000,"date":"2026-10-22T05:30:00.000Z"},{"value":0.9,"timestamp":1792648800,"date":"2026-10-22T06:00:00.000Z"},{"value":0.9,"timestamp":1792650600,"date":"2026-10-22T06:30:00.000Z"},{"value":0.88,"timestamp":1792652400,"date":"2026-10-22T07:00:00.000Z"},{"value":0.87,"timestamp":1792654200,"date":"2026-10-22T07:30:00.000Z"},{"value":0.84,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.82,"timestamp":1792657800,"date":"2026-10-22T08:30:00.000Z"},{"value":0.82,"timestamp":1792659600,"date":"2026-10-22T09:00:00.000Z"},{"value":0.83,"timestamp":1792661400,"date":"2026-10-22T09:30:00.000Z"},{"value":0.85,"timestamp":1792663200,"date":"2026-10-22T10:00:00.000Z"},{"value":0.85,"timestamp":1792665000,"date":"2026-10-22T10:30:00.000Z"},{"value":0.84,"timestamp":1792666800,"date":"2026-10-22T11:00:00.000Z"},{"value":0.83,"timestamp":1792668600,"date":"2026-10-22T11:30:00.000Z"},{"value":0.83,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.81,"timestamp":1792672200,"date":"2026-10-22T12:30:00.000Z"},{"value":0.83,"timestamp":1792674000,"date":"2026-10-22T13:00:00.000Z"},{"value":0.8,"timestamp":1792675800,"date":"2026-10-22T13:30:00.000Z"},{"value":0.81,"timestamp":1792677600,"date":"2026-10-22T14:00:00.000Z"},{"value":0.83,"timestamp":1792679400,"date":"2026-10-22T14:30:00.000Z"},{"value":0.8,"timestamp":1792681200,"date":"2026-10-22T15:00:00.000Z"},{"value":0.81,"timestamp":1792683000,"date":"2026-10-22T15:30:00.000Z"},{"value":0.81,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.75,"timestamp":1792686600,"date":"2026-10-22T16:30:00.000Z"},{"value":0.76,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.75,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.74,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.77,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.79,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.79,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.78,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.77,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.81,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.81,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.79,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.79,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.79,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.81,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.76,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.81,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.81,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.805,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.83,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.88,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.88,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.9,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.85,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.84,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.855,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.875,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.88,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.88,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.87,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.84,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.88,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.88,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.87,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.87,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.88,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.88,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.88,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.89,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.89,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.89,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.89,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.91,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.84,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.91,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.88,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.9,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"},{"value":0.91,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"}],"change_percent":0.18954248366013074},{"timeframe":"30d","prices":[{"value":0.76,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.74,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.725,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.75,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.755,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.735,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.72,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.825,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.84,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.88,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.86,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.895,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.925,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.96,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.97,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.96,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.91,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.87,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.83,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.81,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.79,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.81,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.9,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.84,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.89,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.9,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.91,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"}],"change_percent":0.1973684210526316},{"timeframe":"all","prices":[{"value":0.76,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.74,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.725,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.75,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.755,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.735,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.72,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.825,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.84,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.88,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.86,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.895,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.925,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.96,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.97,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.96,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.91,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.87,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.83,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.81,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.79,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.81,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.9,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.84,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.89,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.9,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.91,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"}],"change_percent":0.1973684210526316}]},{"id":1,"title":"No","shares":16.17058518,"sharesHeld":16.17058518,"price":0.09,"closingPrice":null,"priceChange24h":-0.52631579,"bestBid":0.07,"bestAsk":0.11,"imageUrl":null,"holders":1,"tokenId":"7445","price_charts":[{"timeframe":"24h","prices":[{"value":0.19,"timestamp":1792686300,"date":"2026-10-22T16:25:00.000Z"},{"value":0.21,"timestamp":1792686600,"date":"2026-10-22T16:30:00.000Z"},{"value":0.21,"timestamp":1792686900,"date":"2026-10-22T16:35:00.000Z"},{"value":0.22,"timestamp":1792687200,"date":"2026-10-22T16:40:00.000Z"},{"value":0.27,"timestamp":1792687500,"date":"2026-10-22T16:45:00.000Z"},{"value":0.29,"timestamp":1792687800,"date":"2026-10-22T16:50:00.000Z"},{"value":0.25,"timestamp":1792688100,"date":"2026-10-22T16:55:00.000Z"},{"value":0.25,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.25,"timestamp":1792688700,"date":"2026-10-22T17:05:00.000Z"},{"value":0.26,"timestamp":1792689000,"date":"2026-10-22T17:10:00.000Z"},{"value":0.24,"timestamp":1792689300,"date":"2026-10-22T17:15:00.000Z"},{"value":0.23,"timestamp":1792689600,"date":"2026-10-22T17:20:00.000Z"},{"value":0.24,"timestamp":1792689900,"date":"2026-10-22T17:25:00.000Z"},{"value":0.24,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.24,"timestamp":1792690500,"date":"2026-10-22T17:35:00.000Z"},{"value":0.24,"timestamp":1792690800,"date":"2026-10-22T17:40:00.000Z"},{"value":0.24,"timestamp":1792691100,"date":"2026-10-22T17:45:00.000Z"},{"value":0.24,"timestamp":1792691400,"date":"2026-10-22T17:50:00.000Z"},{"value":0.25,"timestamp":1792691700,"date":"2026-10-22T17:55:00.000Z"},{"value":0.28,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.27,"timestamp":1792692300,"date":"2026-10-22T18:05:00.000Z"},{"value":0.27,"timestamp":1792692600,"date":"2026-10-22T18:10:00.000Z"},{"value":0.27,"timestamp":1792692900,"date":"2026-10-22T18:15:00.000Z"},{"value":0.25,"timestamp":1792693200,"date":"2026-10-22T18:20:00.000Z"},{"value":0.26,"timestamp":1792693500,"date":"2026-10-22T18:25:00.000Z"},{"value":0.26,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.25,"timestamp":1792694100,"date":"2026-10-22T18:35:00.000Z"},{"value":0.24,"timestamp":1792694400,"date":"2026-10-22T18:40:00.000Z"},{"value":0.225,"timestamp":1792694700,"date":"2026-10-22T18:45:00.000Z"},{"value":0.23,"timestamp":1792695000,"date":"2026-10-22T18:50:00.000Z"},{"value":0.23,"timestamp":1792695300,"date":"2026-10-22T18:55:00.000Z"},{"value":0.22,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.23,"timestamp":1792695900,"date":"2026-10-22T19:05:00.000Z"},{"value":0.23,"timestamp":1792696200,"date":"2026-10-22T19:10:00.000Z"},{"value":0.22,"timestamp":1792696500,"date":"2026-10-22T19:15:00.000Z"},{"value":0.21,"timestamp":1792696800,"date":"2026-10-22T19:20:00.000Z"},{"value":0.21,"timestamp":1792697100,"date":"2026-10-22T19:25:00.000Z"},{"value":0.2,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.21,"timestamp":1792697700,"date":"2026-10-22T19:35:00.000Z"},{"value":0.22,"timestamp":1792698000,"date":"2026-10-22T19:40:00.000Z"},{"value":0.22,"timestamp":1792698300,"date":"2026-10-22T19:45:00.000Z"},{"value":0.2,"timestamp":1792698600,"date":"2026-10-22T19:50:00.000Z"},{"value":0.21,"timestamp":1792698900,"date":"2026-10-22T19:55:00.000Z"},{"value":0.21,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.21,"timestamp":1792699500,"date":"2026-10-22T20:05:00.000Z"},{"value":0.21,"timestamp":1792699800,"date":"2026-10-22T20:10:00.000Z"},{"value":0.22,"timestamp":1792700100,"date":"2026-10-22T20:15:00.000Z"},{"value":0.22,"timestamp":1792700400,"date":"2026-10-22T20:20:00.000Z"},{"value":0.22,"timestamp":1792700700,"date":"2026-10-22T20:25:00.000Z"},{"value":0.22,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.22,"timestamp":1792701300,"date":"2026-10-22T20:35:00.000Z"},{"value":0.23,"timestamp":1792701600,"date":"2026-10-22T20:40:00.000Z"},{"value":0.24,"timestamp":1792701900,"date":"2026-10-22T20:45:00.000Z"},{"value":0.24,"timestamp":1792702200,"date":"2026-10-22T20:50:00.000Z"},{"value":0.23,"timestamp":1792702500,"date":"2026-10-22T20:55:00.000Z"},{"value":0.24,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.21,"timestamp":1792703100,"date":"2026-10-22T21:05:00.000Z"},{"value":0.21,"timestamp":1792703400,"date":"2026-10-22T21:10:00.000Z"},{"value":0.19,"timestamp":1792703700,"date":"2026-10-22T21:15:00.000Z"},{"value":0.19,"timestamp":1792704000,"date":"2026-10-22T21:20:00.000Z"},{"value":0.19,"timestamp":1792704300,"date":"2026-10-22T21:25:00.000Z"},{"value":0.2,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.19,"timestamp":1792704900,"date":"2026-10-22T21:35:00.000Z"},{"value":0.19,"timestamp":1792705200,"date":"2026-10-22T21:40:00.000Z"},{"value":0.19,"timestamp":1792705500,"date":"2026-10-22T21:45:00.000Z"},{"value":0.19,"timestamp":1792705800,"date":"2026-10-22T21:50:00.000Z"},{"value":0.19,"timestamp":1792706100,"date":"2026-10-22T21:55:00.000Z"},{"value":0.19,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.19,"timestamp":1792706700,"date":"2026-10-22T22:05:00.000Z"},{"value":0.19,"timestamp":1792707000,"date":"2026-10-22T22:10:00.000Z"},{"value":0.21,"timestamp":1792707300,"date":"2026-10-22T22:15:00.000Z"},{"value":0.21,"timestamp":1792707600,"date":"2026-10-22T22:20:00.000Z"},{"value":0.21,"timestamp":1792707900,"date":"2026-10-22T22:25:00.000Z"},{"value":0.21,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.2,"timestamp":1792708500,"date":"2026-10-22T22:35:00.000Z"},{"value":0.22,"timestamp":1792708800,"date":"2026-10-22T22:40:00.000Z"},{"value":0.22,"timestamp":1792709100,"date":"2026-10-22T22:45:00.000Z"},{"value":0.21,"timestamp":1792709400,"date":"2026-10-22T22:50:00.000Z"},{"value":0.21,"timestamp":1792709700,"date":"2026-10-22T22:55:00.000Z"},{"value":0.21,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.21,"timestamp":1792710300,"date":"2026-10-22T23:05:00.000Z"},{"value":0.21,"timestamp":1792710600,"date":"2026-10-22T23:10:00.000Z"},{"value":0.21,"timestamp":1792710900,"date":"2026-10-22T23:15:00.000Z"},{"value":0.21,"timestamp":1792711200,"date":"2026-10-22T23:20:00.000Z"},{"value":0.21,"timestamp":1792711500,"date":"2026-10-22T23:25:00.000Z"},{"value":0.21,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.21,"timestamp":1792712100,"date":"2026-10-22T23:35:00.000Z"},{"value":0.21,"timestamp":1792712400,"date":"2026-10-22T23:40:00.000Z"},{"value":0.2,"timestamp":1792712700,"date":"2026-10-22T23:45:00.000Z"},{"value":0.2,"timestamp":1792713000,"date":"2026-10-22T23:50:00.000Z"},{"value":0.19,"timestamp":1792713300,"date":"2026-10-22T23:55:00.000Z"},{"value":0.22,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.22,"timestamp":1792713900,"date":"2026-10-23T00:05:00.000Z"},{"value":0.23,"timestamp":1792714200,"date":"2026-10-23T00:10:00.000Z"},{"value":0.23,"timestamp":1792714500,"date":"2026-10-23T00:15:00.000Z"},{"value":0.24,"timestamp":1792714800,"date":"2026-10-23T00:20:00.000Z"},{"value":0.24,"timestamp":1792715100,"date":"2026-10-23T00:25:00.000Z"},{"value":0.22,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.22,"timestamp":1792715700,"date":"2026-10-23T00:35:00.000Z"},{"value":0.2,"timestamp":1792716000,"date":"2026-10-23T00:40:00.000Z"},{"value":0.19,"timestamp":1792716300,"date":"2026-10-23T00:45:00.000Z"},{"value":0.19,"timestamp":1792716600,"date":"2026-10-23T00:50:00.000Z"},{"value":0.19,"timestamp":1792716900,"date":"2026-10-23T00:55:00.000Z"},{"value":0.2,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.21,"timestamp":1792717500,"date":"2026-10-23T01:05:00.000Z"},{"value":0.2,"timestamp":1792717800,"date":"2026-10-23T01:10:00.000Z"},{"value":0.21,"timestamp":1792718100,"date":"2026-10-23T01:15:00.000Z"},{"value":0.2,"timestamp":1792718400,"date":"2026-10-23T01:20:00.000Z"},{"value":0.19,"timestamp":1792718700,"date":"2026-10-23T01:25:00.000Z"},{"value":0.2,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.23,"timestamp":1792719300,"date":"2026-10-23T01:35:00.000Z"},{"value":0.2,"timestamp":1792719600,"date":"2026-10-23T01:40:00.000Z"},{"value":0.16,"timestamp":1792719900,"date":"2026-10-23T01:45:00.000Z"},{"value":0.17,"timestamp":1792720200,"date":"2026-10-23T01:50:00.000Z"},{"value":0.195,"timestamp":1792720500,"date":"2026-10-23T01:55:00.000Z"},{"value":0.22,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.22,"timestamp":1792721100,"date":"2026-10-23T02:05:00.000Z"},{"value":0.2,"timestamp":1792721400,"date":"2026-10-23T02:10:00.000Z"},{"value":0.2,"timestamp":1792721700,"date":"2026-10-23T02:15:00.000Z"},{"value":0.18,"timestamp":1792722000,"date":"2026-10-23T02:20:00.000Z"},{"value":0.17,"timestamp":1792722300,"date":"2026-10-23T02:25:00.000Z"},{"value":0.13,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.13,"timestamp":1792722900,"date":"2026-10-23T02:35:00.000Z"},{"value":0.13,"timestamp":1792723200,"date":"2026-10-23T02:40:00.000Z"},{"value":0.13,"timestamp":1792723500,"date":"2026-10-23T02:45:00.000Z"},{"value":0.12,"timestamp":1792723800,"date":"2026-10-23T02:50:00.000Z"},{"value":0.12,"timestamp":1792724100,"date":"2026-10-23T02:55:00.000Z"},{"value":0.12,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.12,"timestamp":1792724700,"date":"2026-10-23T03:05:00.000Z"},{"value":0.12,"timestamp":1792725000,"date":"2026-10-23T03:10:00.000Z"},{"value":0.12,"timestamp":1792725300,"date":"2026-10-23T03:15:00.000Z"},{"value":0.12,"timestamp":1792725600,"date":"2026-10-23T03:20:00.000Z"},{"value":0.12,"timestamp":1792725900,"date":"2026-10-23T03:25:00.000Z"},{"value":0.12,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.12,"timestamp":1792726500,"date":"2026-10-23T03:35:00.000Z"},{"value":0.12,"timestamp":1792726800,"date":"2026-10-23T03:40:00.000Z"},{"value":0.11,"timestamp":1792727100,"date":"2026-10-23T03:45:00.000Z"},{"value":0.1,"timestamp":1792727400,"date":"2026-10-23T03:50:00.000Z"},{"value":0.1,"timestamp":1792727700,"date":"2026-10-23T03:55:00.000Z"},{"value":0.1,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.13,"timestamp":1792728300,"date":"2026-10-23T04:05:00.000Z"},{"value":0.13,"timestamp":1792728600,"date":"2026-10-23T04:10:00.000Z"},{"value":0.13,"timestamp":1792728900,"date":"2026-10-23T04:15:00.000Z"},{"value":0.13,"timestamp":1792729200,"date":"2026-10-23T04:20:00.000Z"},{"value":0.15,"timestamp":1792729500,"date":"2026-10-23T04:25:00.000Z"},{"value":0.15,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.14,"timestamp":1792730100,"date":"2026-10-23T04:35:00.000Z"},{"value":0.14,"timestamp":1792730400,"date":"2026-10-23T04:40:00.000Z"},{"value":0.14,"timestamp":1792730700,"date":"2026-10-23T04:45:00.000Z"},{"value":0.145,"timestamp":1792731000,"date":"2026-10-23T04:50:00.000Z"},{"value":0.16,"timestamp":1792731300,"date":"2026-10-23T04:55:00.000Z"},{"value":0.15,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.14,"timestamp":1792731900,"date":"2026-10-23T05:05:00.000Z"},{"value":0.13,"timestamp":1792732200,"date":"2026-10-23T05:10:00.000Z"},{"value":0.13,"timestamp":1792732500,"date":"2026-10-23T05:15:00.000Z"},{"value":0.14,"timestamp":1792732800,"date":"2026-10-23T05:20:00.000Z"},{"value":0.145,"timestamp":1792733100,"date":"2026-10-23T05:25:00.000Z"},{"value":0.14,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.14,"timestamp":1792733700,"date":"2026-10-23T05:35:00.000Z"},{"value":0.14,"timestamp":1792734000,"date":"2026-10-23T05:40:00.000Z"},{"value":0.13,"timestamp":1792734300,"date":"2026-10-23T05:45:00.000Z"},{"value":0.13,"timestamp":1792734600,"date":"2026-10-23T05:50:00.000Z"},{"value":0.125,"timestamp":1792734900,"date":"2026-10-23T05:55:00.000Z"},{"value":0.13,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.13,"timestamp":1792735500,"date":"2026-10-23T06:05:00.000Z"},{"value":0.13,"timestamp":1792735800,"date":"2026-10-23T06:10:00.000Z"},{"value":0.12,"timestamp":1792736100,"date":"2026-10-23T06:15:00.000Z"},{"value":0.12,"timestamp":1792736400,"date":"2026-10-23T06:20:00.000Z"},{"value":0.12,"timestamp":1792736700,"date":"2026-10-23T06:25:00.000Z"},{"value":0.12,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.12,"timestamp":1792737300,"date":"2026-10-23T06:35:00.000Z"},{"value":0.12,"timestamp":1792737600,"date":"2026-10-23T06:40:00.000Z"},{"value":0.12,"timestamp":1792737900,"date":"2026-10-23T06:45:00.000Z"},{"value":0.12,"timestamp":1792738200,"date":"2026-10-23T06:50:00.000Z"},{"value":0.12,"timestamp":1792738500,"date":"2026-10-23T06:55:00.000Z"},{"value":0.125,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.12,"timestamp":1792739100,"date":"2026-10-23T07:05:00.000Z"},{"value":0.13,"timestamp":1792739400,"date":"2026-10-23T07:10:00.000Z"},{"value":0.13,"timestamp":1792739700,"date":"2026-10-23T07:15:00.000Z"},{"value":0.13,"timestamp":1792740000,"date":"2026-10-23T07:20:00.000Z"},{"value":0.13,"timestamp":1792740300,"date":"2026-10-23T07:25:00.000Z"},{"value":0.13,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.125,"timestamp":1792740900,"date":"2026-10-23T07:35:00.000Z"},{"value":0.14,"timestamp":1792741200,"date":"2026-10-23T07:40:00.000Z"},{"value":0.15,"timestamp":1792741500,"date":"2026-10-23T07:45:00.000Z"},{"value":0.16,"timestamp":1792741800,"date":"2026-10-23T07:50:00.000Z"},{"value":0.16,"timestamp":1792742100,"date":"2026-10-23T07:55:00.000Z"},{"value":0.16,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.15,"timestamp":1792742700,"date":"2026-10-23T08:05:00.000Z"},{"value":0.14,"timestamp":1792743000,"date":"2026-10-23T08:10:00.000Z"},{"value":0.12,"timestamp":1792743300,"date":"2026-10-23T08:15:00.000Z"},{"value":0.12,"timestamp":1792743600,"date":"2026-10-23T08:20:00.000Z"},{"value":0.12,"timestamp":1792743900,"date":"2026-10-23T08:25:00.000Z"},{"value":0.12,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.12,"timestamp":1792744500,"date":"2026-10-23T08:35:00.000Z"},{"value":0.11,"timestamp":1792744800,"date":"2026-10-23T08:40:00.000Z"},{"value":0.12,"timestamp":1792745100,"date":"2026-10-23T08:45:00.000Z"},{"value":0.12,"timestamp":1792745400,"date":"2026-10-23T08:50:00.000Z"},{"value":0.12,"timestamp":1792745700,"date":"2026-10-23T08:55:00.000Z"},{"value":0.12,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.12,"timestamp":1792746300,"date":"2026-10-23T09:05:00.000Z"},{"value":0.12,"timestamp":1792746600,"date":"2026-10-23T09:10:00.000Z"},{"value":0.12,"timestamp":1792746900,"date":"2026-10-23T09:15:00.000Z"},{"value":0.12,"timestamp":1792747200,"date":"2026-10-23T09:20:00.000Z"},{"value":0.13,"timestamp":1792747500,"date":"2026-10-23T09:25:00.000Z"},{"value":0.14,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.15,"timestamp":1792748100,"date":"2026-10-23T09:35:00.000Z"},{"value":0.14,"timestamp":1792748400,"date":"2026-10-23T09:40:00.000Z"},{"value":0.14,"timestamp":1792748700,"date":"2026-10-23T09:45:00.000Z"},{"value":0.13,"timestamp":1792749000,"date":"2026-10-23T09:50:00.000Z"},{"value":0.13,"timestamp":1792749300,"date":"2026-10-23T09:55:00.000Z"},{"value":0.13,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.12,"timestamp":1792749900,"date":"2026-10-23T10:05:00.000Z"},{"value":0.12,"timestamp":1792750200,"date":"2026-10-23T10:10:00.000Z"},{"value":0.11,"timestamp":1792750500,"date":"2026-10-23T10:15:00.000Z"},{"value":0.11,"timestamp":1792750800,"date":"2026-10-23T10:20:00.000Z"},{"value":0.12,"timestamp":1792751100,"date":"2026-10-23T10:25:00.000Z"},{"value":0.12,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.12,"timestamp":1792751700,"date":"2026-10-23T10:35:00.000Z"},{"value":0.12,"timestamp":1792752000,"date":"2026-10-23T10:40:00.000Z"},{"value":0.12,"timestamp":1792752300,"date":"2026-10-23T10:45:00.000Z"},{"value":0.12,"timestamp":1792752600,"date":"2026-10-23T10:50:00.000Z"},{"value":0.12,"timestamp":1792752900,"date":"2026-10-23T10:55:00.000Z"},{"value":0.13,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.13,"timestamp":1792753500,"date":"2026-10-23T11:05:00.000Z"},{"value":0.13,"timestamp":1792753800,"date":"2026-10-23T11:10:00.000Z"},{"value":0.13,"timestamp":1792754100,"date":"2026-10-23T11:15:00.000Z"},{"value":0.13,"timestamp":1792754400,"date":"2026-10-23T11:20:00.000Z"},{"value":0.12,"timestamp":1792754700,"date":"2026-10-23T11:25:00.000Z"},{"value":0.12,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.12,"timestamp":1792755300,"date":"2026-10-23T11:35:00.000Z"},{"value":0.11,"timestamp":1792755600,"date":"2026-10-23T11:40:00.000Z"},{"value":0.11,"timestamp":1792755900,"date":"2026-10-23T11:45:00.000Z"},{"value":0.11,"timestamp":1792756200,"date":"2026-10-23T11:50:00.000Z"},{"value":0.11,"timestamp":1792756500,"date":"2026-10-23T11:55:00.000Z"},{"value":0.12,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.12,"timestamp":1792757100,"date":"2026-10-23T12:05:00.000Z"},{"value":0.13,"timestamp":1792757400,"date":"2026-10-23T12:10:00.000Z"},{"value":0.11,"timestamp":1792757700,"date":"2026-10-23T12:15:00.000Z"},{"value":0.12,"timestamp":1792758000,"date":"2026-10-23T12:20:00.000Z"},{"value":0.11,"timestamp":1792758300,"date":"2026-10-23T12:25:00.000Z"},{"value":0.1,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.1,"timestamp":1792758900,"date":"2026-10-23T12:35:00.000Z"},{"value":0.1,"timestamp":1792759200,"date":"2026-10-23T12:40:00.000Z"},{"value":0.11,"timestamp":1792759500,"date":"2026-10-23T12:45:00.000Z"},{"value":0.11,"timestamp":1792759800,"date":"2026-10-23T12:50:00.000Z"},{"value":0.11,"timestamp":1792760100,"date":"2026-10-23T12:55:00.000Z"},{"value":0.1,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.11,"timestamp":1792760700,"date":"2026-10-23T13:05:00.000Z"},{"value":0.11,"timestamp":1792761000,"date":"2026-10-23T13:10:00.000Z"},{"value":0.115,"timestamp":1792761300,"date":"2026-10-23T13:15:00.000Z"},{"value":0.09,"timestamp":1792761600,"date":"2026-10-23T13:20:00.000Z"},{"value":0.11,"timestamp":1792761900,"date":"2026-10-23T13:25:00.000Z"},{"value":0.11,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.11,"timestamp":1792762500,"date":"2026-10-23T13:35:00.000Z"},{"value":0.09,"timestamp":1792762800,"date":"2026-10-23T13:40:00.000Z"},{"value":0.09,"timestamp":1792763100,"date":"2026-10-23T13:45:00.000Z"},{"value":0.09,"timestamp":1792763400,"date":"2026-10-23T13:50:00.000Z"},{"value":0.09,"timestamp":1792763700,"date":"2026-10-23T13:55:00.000Z"},{"value":0.09,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.1,"timestamp":1792764300,"date":"2026-10-23T14:05:00.000Z"},{"value":0.13,"timestamp":1792764600,"date":"2026-10-23T14:10:00.000Z"},{"value":0.2,"timestamp":1792764900,"date":"2026-10-23T14:15:00.000Z"},{"value":0.17,"timestamp":1792765200,"date":"2026-10-23T14:20:00.000Z"},{"value":0.16,"timestamp":1792765500,"date":"2026-10-23T14:25:00.000Z"},{"value":0.15,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.1,"timestamp":1792766100,"date":"2026-10-23T14:35:00.000Z"},{"value":0.1,"timestamp":1792766400,"date":"2026-10-23T14:40:00.000Z"},{"value":0.1,"timestamp":1792766700,"date":"2026-10-23T14:45:00.000Z"},{"value":0.08,"timestamp":1792767000,"date":"2026-10-23T14:50:00.000Z"},{"value":0.09,"timestamp":1792767300,"date":"2026-10-23T14:55:00.000Z"},{"value":0.11,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.11,"timestamp":1792767900,"date":"2026-10-23T15:05:00.000Z"},{"value":0.12,"timestamp":1792768200,"date":"2026-10-23T15:10:00.000Z"},{"value":0.12,"timestamp":1792768500,"date":"2026-10-23T15:15:00.000Z"},{"value":0.13,"timestamp":1792768800,"date":"2026-10-23T15:20:00.000Z"},{"value":0.12,"timestamp":1792769100,"date":"2026-10-23T15:25:00.000Z"},{"value":0.11,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"},{"value":0.12,"timestamp":1792769700,"date":"2026-10-23T15:35:00.000Z"},{"value":0.12,"timestamp":1792770000,"date":"2026-10-23T15:40:00.000Z"},{"value":0.11,"timestamp":1792770300,"date":"2026-10-23T15:45:00.000Z"},{"value":0.1,"timestamp":1792770600,"date":"2026-10-23T15:50:00.000Z"},{"value":0.1,"timestamp":1792770900,"date":"2026-10-23T15:55:00.000Z"},{"value":0.09,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"},{"value":0.08,"timestamp":1792771500,"date":"2026-10-23T16:05:00.000Z"},{"value":0.09,"timestamp":1792771800,"date":"2026-10-23T16:10:00.000Z"},{"value":0.09,"timestamp":1792772100,"date":"2026-10-23T16:15:00.000Z"},{"value":0.09,"timestamp":1792772400,"date":"2026-10-23T16:20:00.000Z"}],"change_percent":-0.5263157894736842},{"timeframe":"7d","prices":[{"value":0.235,"timestamp":1792404000,"date":"2026-10-19T10:00:00.000Z"},{"value":0.23,"timestamp":1792405800,"date":"2026-10-19T10:30:00.000Z"},{"value":0.245,"timestamp":1792407600,"date":"2026-10-19T11:00:00.000Z"},{"value":0.24,"timestamp":1792409400,"date":"2026-10-19T11:30:00.000Z"},{"value":0.225,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.22,"timestamp":1792413000,"date":"2026-10-19T12:30:00.000Z"},{"value":0.215,"timestamp":1792414800,"date":"2026-10-19T13:00:00.000Z"},{"value":0.245,"timestamp":1792416600,"date":"2026-10-19T13:30:00.000Z"},{"value":0.255,"timestamp":1792418400,"date":"2026-10-19T14:00:00.000Z"},{"value":0.24,"timestamp":1792420200,"date":"2026-10-19T14:30:00.000Z"},{"value":0.22,"timestamp":1792422000,"date":"2026-10-19T15:00:00.000Z"},{"value":0.26,"timestamp":1792423800,"date":"2026-10-19T15:30:00.000Z"},{"value":0.25,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.255,"timestamp":1792427400,"date":"2026-10-19T16:30:00.000Z"},{"value":0.255,"timestamp":1792429200,"date":"2026-10-19T17:00:00.000Z"},{"value":0.265,"timestamp":1792431000,"date":"2026-10-19T17:30:00.000Z"},{"value":0.26,"timestamp":1792432800,"date":"2026-10-19T18:00:00.000Z"},{"value":0.255,"timestamp":1792434600,"date":"2026-10-19T18:30:00.000Z"},{"value":0.27,"timestamp":1792436400,"date":"2026-10-19T19:00:00.000Z"},{"value":0.275,"timestamp":1792438200,"date":"2026-10-19T19:30:00.000Z"},{"value":0.245,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.25,"timestamp":1792441800,"date":"2026-10-19T20:30:00.000Z"},{"value":0.24,"timestamp":1792443600,"date":"2026-10-19T21:00:00.000Z"},{"value":0.245,"timestamp":1792445400,"date":"2026-10-19T21:30:00.000Z"},{"value":0.235,"timestamp":1792447200,"date":"2026-10-19T22:00:00.000Z"},{"value":0.24,"timestamp":1792449000,"date":"2026-10-19T22:30:00.000Z"},{"value":0.255,"timestamp":1792450800,"date":"2026-10-19T23:00:00.000Z"},{"value":0.25,"timestamp":1792452600,"date":"2026-10-19T23:30:00.000Z"},{"value":0.295,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.3,"timestamp":1792456200,"date":"2026-10-20T00:30:00.000Z"},{"value":0.335,"timestamp":1792458000,"date":"2026-10-20T01:00:00.000Z"},{"value":0.28,"timestamp":1792459800,"date":"2026-10-20T01:30:00.000Z"},{"value":0.295,"timestamp":1792461600,"date":"2026-10-20T02:00:00.000Z"},{"value":0.275,"timestamp":1792463400,"date":"2026-10-20T02:30:00.000Z"},{"value":0.275,"timestamp":1792465200,"date":"2026-10-20T03:00:00.000Z"},{"value":0.245,"timestamp":1792467000,"date":"2026-10-20T03:30:00.000Z"},{"value":0.235,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.235,"timestamp":1792470600,"date":"2026-10-20T04:30:00.000Z"},{"value":0.235,"timestamp":1792472400,"date":"2026-10-20T05:00:00.000Z"},{"value":0.265,"timestamp":1792474200,"date":"2026-10-20T05:30:00.000Z"},{"value":0.275,"timestamp":1792476000,"date":"2026-10-20T06:00:00.000Z"},{"value":0.26,"timestamp":1792477800,"date":"2026-10-20T06:30:00.000Z"},{"value":0.27,"timestamp":1792479600,"date":"2026-10-20T07:00:00.000Z"},{"value":0.265,"timestamp":1792481400,"date":"2026-10-20T07:30:00.000Z"},{"value":0.29,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.295,"timestamp":1792485000,"date":"2026-10-20T08:30:00.000Z"},{"value":0.295,"timestamp":1792486800,"date":"2026-10-20T09:00:00.000Z"},{"value":0.29,"timestamp":1792488600,"date":"2026-10-20T09:30:00.000Z"},{"value":0.275,"timestamp":1792490400,"date":"2026-10-20T10:00:00.000Z"},{"value":0.275,"timestamp":1792492200,"date":"2026-10-20T10:30:00.000Z"},{"value":0.28,"timestamp":1792494000,"date":"2026-10-20T11:00:00.000Z"},{"value":0.28,"timestamp":1792495800,"date":"2026-10-20T11:30:00.000Z"},{"value":0.27,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.27,"timestamp":1792499400,"date":"2026-10-20T12:30:00.000Z"},{"value":0.3,"timestamp":1792501200,"date":"2026-10-20T13:00:00.000Z"},{"value":0.205,"timestamp":1792503000,"date":"2026-10-20T13:30:00.000Z"},{"value":0.15,"timestamp":1792504800,"date":"2026-10-20T14:00:00.000Z"},{"value":0.12,"timestamp":1792506600,"date":"2026-10-20T14:30:00.000Z"},{"value":0.19,"timestamp":1792508400,"date":"2026-10-20T15:00:00.000Z"},{"value":0.175,"timestamp":1792510200,"date":"2026-10-20T15:30:00.000Z"},{"value":0.17,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.16,"timestamp":1792513800,"date":"2026-10-20T16:30:00.000Z"},{"value":0.155,"timestamp":1792515600,"date":"2026-10-20T17:00:00.000Z"},{"value":0.155,"timestamp":1792517400,"date":"2026-10-20T17:30:00.000Z"},{"value":0.165,"timestamp":1792519200,"date":"2026-10-20T18:00:00.000Z"},{"value":0.165,"timestamp":1792521000,"date":"2026-10-20T18:30:00.000Z"},{"value":0.17,"timestamp":1792522800,"date":"2026-10-20T19:00:00.000Z"},{"value":0.16,"timestamp":1792524600,"date":"2026-10-20T19:30:00.000Z"},{"value":0.16,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.155,"timestamp":1792528200,"date":"2026-10-20T20:30:00.000Z"},{"value":0.155,"timestamp":1792530000,"date":"2026-10-20T21:00:00.000Z"},{"value":0.135,"timestamp":1792531800,"date":"2026-10-20T21:30:00.000Z"},{"value":0.12,"timestamp":1792533600,"date":"2026-10-20T22:00:00.000Z"},{"value":0.12,"timestamp":1792535400,"date":"2026-10-20T22:30:00.000Z"},{"value":0.12,"timestamp":1792537200,"date":"2026-10-20T23:00:00.000Z"},{"value":0.12,"timestamp":1792539000,"date":"2026-10-20T23:30:00.000Z"},{"value":0.115,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.115,"timestamp":1792542600,"date":"2026-10-21T00:30:00.000Z"},{"value":0.125,"timestamp":1792544400,"date":"2026-10-21T01:00:00.000Z"},{"value":0.115,"timestamp":1792546200,"date":"2026-10-21T01:30:00.000Z"},{"value":0.13,"timestamp":1792548000,"date":"2026-10-21T02:00:00.000Z"},{"value":0.13,"timestamp":1792549800,"date":"2026-10-21T02:30:00.000Z"},{"value":0.13,"timestamp":1792551600,"date":"2026-10-21T03:00:00.000Z"},{"value":0.14,"timestamp":1792553400,"date":"2026-10-21T03:30:00.000Z"},{"value":0.135,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.135,"timestamp":1792557000,"date":"2026-10-21T04:30:00.000Z"},{"value":0.135,"timestamp":1792558800,"date":"2026-10-21T05:00:00.000Z"},{"value":0.15,"timestamp":1792560600,"date":"2026-10-21T05:30:00.000Z"},{"value":0.155,"timestamp":1792562400,"date":"2026-10-21T06:00:00.000Z"},{"value":0.145,"timestamp":1792564200,"date":"2026-10-21T06:30:00.000Z"},{"value":0.12,"timestamp":1792566000,"date":"2026-10-21T07:00:00.000Z"},{"value":0.105,"timestamp":1792567800,"date":"2026-10-21T07:30:00.000Z"},{"value":0.105,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.105,"timestamp":1792571400,"date":"2026-10-21T08:30:00.000Z"},{"value":0.08,"timestamp":1792573200,"date":"2026-10-21T09:00:00.000Z"},{"value":0.075,"timestamp":1792575000,"date":"2026-10-21T09:30:00.000Z"},{"value":0.08,"timestamp":1792576800,"date":"2026-10-21T10:00:00.000Z"},{"value":0.08,"timestamp":1792578600,"date":"2026-10-21T10:30:00.000Z"},{"value":0.075,"timestamp":1792580400,"date":"2026-10-21T11:00:00.000Z"},{"value":0.075,"timestamp":1792582200,"date":"2026-10-21T11:30:00.000Z"},{"value":0.075,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.075,"timestamp":1792585800,"date":"2026-10-21T12:30:00.000Z"},{"value":0.06,"timestamp":1792587600,"date":"2026-10-21T13:00:00.000Z"},{"value":0.06,"timestamp":1792589400,"date":"2026-10-21T13:30:00.000Z"},{"value":0.065,"timestamp":1792591200,"date":"2026-10-21T14:00:00.000Z"},{"value":0.05,"timestamp":1792593000,"date":"2026-10-21T14:30:00.000Z"},{"value":0.04,"timestamp":1792594800,"date":"2026-10-21T15:00:00.000Z"},{"value":0.04,"timestamp":1792596600,"date":"2026-10-21T15:30:00.000Z"},{"value":0.035,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.035,"timestamp":1792600200,"date":"2026-10-21T16:30:00.000Z"},{"value":0.04,"timestamp":1792602000,"date":"2026-10-21T17:00:00.000Z"},{"value":0.03,"timestamp":1792603800,"date":"2026-10-21T17:30:00.000Z"},{"value":0.03,"timestamp":1792605600,"date":"2026-10-21T18:00:00.000Z"},{"value":0.03,"timestamp":1792607400,"date":"2026-10-21T18:30:00.000Z"},{"value":0.035,"timestamp":1792609200,"date":"2026-10-21T19:00:00.000Z"},{"value":0.03,"timestamp":1792611000,"date":"2026-10-21T19:30:00.000Z"},{"value":0.03,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.035,"timestamp":1792614600,"date":"2026-10-21T20:30:00.000Z"},{"value":0.035,"timestamp":1792616400,"date":"2026-10-21T21:00:00.000Z"},{"value":0.035,"timestamp":1792618200,"date":"2026-10-21T21:30:00.000Z"},{"value":0.025,"timestamp":1792620000,"date":"2026-10-21T22:00:00.000Z"},{"value":0.025,"timestamp":1792621800,"date":"2026-10-21T22:30:00.000Z"},{"value":0.025,"timestamp":1792623600,"date":"2026-10-21T23:00:00.000Z"},{"value":0.04,"timestamp":1792625400,"date":"2026-10-21T23:30:00.000Z"},{"value":0.04,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.04,"timestamp":1792629000,"date":"2026-10-22T00:30:00.000Z"},{"value":0.04,"timestamp":1792630800,"date":"2026-10-22T01:00:00.000Z"},{"value":0.06,"timestamp":1792632600,"date":"2026-10-22T01:30:00.000Z"},{"value":0.1,"timestamp":1792634400,"date":"2026-10-22T02:00:00.000Z"},{"value":0.1,"timestamp":1792636200,"date":"2026-10-22T02:30:00.000Z"},{"value":0.1,"timestamp":1792638000,"date":"2026-10-22T03:00:00.000Z"},{"value":0.09,"timestamp":1792639800,"date":"2026-10-22T03:30:00.000Z"},{"value":0.09,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.08,"timestamp":1792643400,"date":"2026-10-22T04:30:00.000Z"},{"value":0.09,"timestamp":1792645200,"date":"2026-10-22T05:00:00.000Z"},{"value":0.09,"timestamp":1792647000,"date":"2026-10-22T05:30:00.000Z"},{"value":0.1,"timestamp":1792648800,"date":"2026-10-22T06:00:00.000Z"},{"value":0.1,"timestamp":1792650600,"date":"2026-10-22T06:30:00.000Z"},{"value":0.12,"timestamp":1792652400,"date":"2026-10-22T07:00:00.000Z"},{"value":0.13,"timestamp":1792654200,"date":"2026-10-22T07:30:00.000Z"},{"value":0.16,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.18,"timestamp":1792657800,"date":"2026-10-22T08:30:00.000Z"},{"value":0.18,"timestamp":1792659600,"date":"2026-10-22T09:00:00.000Z"},{"value":0.17,"timestamp":1792661400,"date":"2026-10-22T09:30:00.000Z"},{"value":0.15,"timestamp":1792663200,"date":"2026-10-22T10:00:00.000Z"},{"value":0.15,"timestamp":1792665000,"date":"2026-10-22T10:30:00.000Z"},{"value":0.16,"timestamp":1792666800,"date":"2026-10-22T11:00:00.000Z"},{"value":0.17,"timestamp":1792668600,"date":"2026-10-22T11:30:00.000Z"},{"value":0.17,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.19,"timestamp":1792672200,"date":"2026-10-22T12:30:00.000Z"},{"value":0.17,"timestamp":1792674000,"date":"2026-10-22T13:00:00.000Z"},{"value":0.2,"timestamp":1792675800,"date":"2026-10-22T13:30:00.000Z"},{"value":0.19,"timestamp":1792677600,"date":"2026-10-22T14:00:00.000Z"},{"value":0.17,"timestamp":1792679400,"date":"2026-10-22T14:30:00.000Z"},{"value":0.2,"timestamp":1792681200,"date":"2026-10-22T15:00:00.000Z"},{"value":0.19,"timestamp":1792683000,"date":"2026-10-22T15:30:00.000Z"},{"value":0.19,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.25,"timestamp":1792686600,"date":"2026-10-22T16:30:00.000Z"},{"value":0.24,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.25,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.26,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.23,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.21,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.21,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.22,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.23,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.19,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.19,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.21,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.21,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.21,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.19,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.24,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.19,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.19,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.195,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.17,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.12,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.12,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.1,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.15,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.16,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.145,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.125,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.12,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.12,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.13,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.16,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.12,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.12,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.13,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.13,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.12,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.12,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.12,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.11,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.11,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.11,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.11,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.09,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.16,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.09,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.12,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.1,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"},{"value":0.09,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"}],"change_percent":-0.6170212765957447},{"timeframe":"30d","prices":[{"value":0.24,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.26,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.275,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.25,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.245,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.265,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.28,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.175,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.16,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.12,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.14,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.105,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.075,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.04,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.03,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.04,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.09,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.13,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.17,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.19,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.21,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.19,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.1,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.16,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.11,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.1,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.09,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"}],"change_percent":-0.625},{"timeframe":"all","prices":[{"value":0.24,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.26,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.275,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.25,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.245,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.265,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.28,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.175,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.16,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.12,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.14,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.105,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.075,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.04,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.03,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.04,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.09,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.13,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.17,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.19,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.21,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.19,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.1,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.16,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.11,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.1,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.09,"timestamp":1792771200,"date":"2026-10-23T16:00:00.000Z"}],"change_percent":-0.625}]}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":0,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"80000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"ff95a6e8-d34f-434c-81b7-5b81cc124482","providerName":"polymarket","externalMarketId":"5889110","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $80,000 on October 25, 4PM UTC?"}]} \ No newline at end of file diff --git a/tests/samples/myriad_market_amm.json b/tests/samples/myriad_market_amm.json new file mode 100644 index 0000000..e1df0a5 --- /dev/null +++ b/tests/samples/myriad_market_amm.json @@ -0,0 +1 @@ +{"id":120473,"networkId":56,"slug":"crude-oil-s-next-move-pump-to-120-or-dump-to-55","title":"Crude Oil’s next move: Pump to $120 or Dump to $55?","shortName":null,"ctaName":null,"description":"### **Market Details:**\n\n- **Market Close:** This market will only be closed once a resolution is achieved.\n- **Resolution Deadline:** The resolution will be determined as soon as an outcome is reached.\n- **Pump Target:** 120.000\n- **Dump Target**: 55.000\n\n### **Resolution Criteria:**\n\n- The market resolves based on which condition is met first:\n - **“$120”** if the Crude Oil WTI (USD/Bbl) at Trading Economics reaches or exceeds the Pump Target.\n - **“$55”** if the Crude Oil WTI (USD/Bbl) at Trading Economics drops to equal or below the Dump Target.\n\n### **Resolution Details:**\n\n- The market resolves based on the one-minute (1m) price updates on the Crude Oil WTI (USD/Bbl) chart on the Trading Economics website.\n- Only the Crude Oil WTI (USD/Bbl) on Trading Economics will be considered.\n\n### **Cancellation (Invalidity) Conditions:**\n\n- The [Crude Oil WTI (USD/Bbl) at Trading Economics](https://tradingeconomics.com/commodity/crude-oil) becomes unavailable, compromised, or fails to update for seven days straight.\n- Any circumstance prevents reliable price tracking.\n- Myriad Markets undergoes a change in its contract that demands a cancellation of all active markets, or a similar significant technical change.\n\nIn the event of cancellation, participants may claim their stakes at the market value of their open positions at the time of cancellation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2026-03-11T15:04:29.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0,"treasury_fee":0,"distributor_fee":0},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Economy"],"tags":[],"resolutionSource":"https://tradingeconomics.com/commodity/crude-oil","resolutionTitle":"Trading Economics","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/e11bc0f6-a97f-4e7b-981e-2340057bb700/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/0d78a1d7-2ce2-469b-2646-710dd71af400/public","liquidity":1500,"liquidityPrice":0.84818529,"volume":8581799.62382441,"volume24h":1520.2862,"volumeNotional":13823192.82922941,"volumeNotional24h":2548.75509245,"users":4091,"shares":2893.87881734,"featured":true,"featuredAt":"2027-05-15T15:56:25.212Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x3288F8C54C9C63D55Ba159e8B6076FcA2960c602","0x6fD3a753943802d0F42B6b885d0Ee496116550b3","0xCEe198c0e83a969768e061c5f5aFD59700087AE2"],"outcomes":[{"id":0,"title":"$120","shares":944.22223028,"sharesHeld":4334.92809017,"price":0.67371743,"closingPrice":null,"priceChange24h":0.07036252,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmYWuwsHv8ZRYthkFc5SF7DZNTfqv8pvG1mJRKW2uzFP2M","holders":98,"tokenId":"240946","price_charts":[{"timeframe":"24h","prices":[{"value":0.6291,"timestamp":1792686900,"date":"2026-10-22T16:35:00.000Z"},{"value":0.6255,"timestamp":1792687200,"date":"2026-10-22T16:40:00.000Z"},{"value":0.6378,"timestamp":1792687500,"date":"2026-10-22T16:45:00.000Z"},{"value":0.6378,"timestamp":1792687800,"date":"2026-10-22T16:50:00.000Z"},{"value":0.6378,"timestamp":1792688100,"date":"2026-10-22T16:55:00.000Z"},{"value":0.6378,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.6378,"timestamp":1792688700,"date":"2026-10-22T17:05:00.000Z"},{"value":0.6378,"timestamp":1792689000,"date":"2026-10-22T17:10:00.000Z"},{"value":0.6378,"timestamp":1792689300,"date":"2026-10-22T17:15:00.000Z"},{"value":0.6378,"timestamp":1792689600,"date":"2026-10-22T17:20:00.000Z"},{"value":0.6378,"timestamp":1792689900,"date":"2026-10-22T17:25:00.000Z"},{"value":0.6378,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.6378,"timestamp":1792690500,"date":"2026-10-22T17:35:00.000Z"},{"value":0.6378,"timestamp":1792690800,"date":"2026-10-22T17:40:00.000Z"},{"value":0.6378,"timestamp":1792691100,"date":"2026-10-22T17:45:00.000Z"},{"value":0.6378,"timestamp":1792691400,"date":"2026-10-22T17:50:00.000Z"},{"value":0.6378,"timestamp":1792691700,"date":"2026-10-22T17:55:00.000Z"},{"value":0.6378,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.6378,"timestamp":1792692300,"date":"2026-10-22T18:05:00.000Z"},{"value":0.6378,"timestamp":1792692600,"date":"2026-10-22T18:10:00.000Z"},{"value":0.6378,"timestamp":1792692900,"date":"2026-10-22T18:15:00.000Z"},{"value":0.6378,"timestamp":1792693200,"date":"2026-10-22T18:20:00.000Z"},{"value":0.6378,"timestamp":1792693500,"date":"2026-10-22T18:25:00.000Z"},{"value":0.6378,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.6378,"timestamp":1792694100,"date":"2026-10-22T18:35:00.000Z"},{"value":0.6378,"timestamp":1792694400,"date":"2026-10-22T18:40:00.000Z"},{"value":0.6378,"timestamp":1792694700,"date":"2026-10-22T18:45:00.000Z"},{"value":0.6378,"timestamp":1792695000,"date":"2026-10-22T18:50:00.000Z"},{"value":0.6378,"timestamp":1792695300,"date":"2026-10-22T18:55:00.000Z"},{"value":0.6378,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.6378,"timestamp":1792695900,"date":"2026-10-22T19:05:00.000Z"},{"value":0.6378,"timestamp":1792696200,"date":"2026-10-22T19:10:00.000Z"},{"value":0.6378,"timestamp":1792696500,"date":"2026-10-22T19:15:00.000Z"},{"value":0.6378,"timestamp":1792696800,"date":"2026-10-22T19:20:00.000Z"},{"value":0.6378,"timestamp":1792697100,"date":"2026-10-22T19:25:00.000Z"},{"value":0.6378,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.6378,"timestamp":1792697700,"date":"2026-10-22T19:35:00.000Z"},{"value":0.6378,"timestamp":1792698000,"date":"2026-10-22T19:40:00.000Z"},{"value":0.6378,"timestamp":1792698300,"date":"2026-10-22T19:45:00.000Z"},{"value":0.6378,"timestamp":1792698600,"date":"2026-10-22T19:50:00.000Z"},{"value":0.6378,"timestamp":1792698900,"date":"2026-10-22T19:55:00.000Z"},{"value":0.6378,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.6378,"timestamp":1792699500,"date":"2026-10-22T20:05:00.000Z"},{"value":0.6378,"timestamp":1792699800,"date":"2026-10-22T20:10:00.000Z"},{"value":0.6378,"timestamp":1792700100,"date":"2026-10-22T20:15:00.000Z"},{"value":0.6378,"timestamp":1792700400,"date":"2026-10-22T20:20:00.000Z"},{"value":0.6378,"timestamp":1792700700,"date":"2026-10-22T20:25:00.000Z"},{"value":0.6378,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.6378,"timestamp":1792701300,"date":"2026-10-22T20:35:00.000Z"},{"value":0.6378,"timestamp":1792701600,"date":"2026-10-22T20:40:00.000Z"},{"value":0.6378,"timestamp":1792701900,"date":"2026-10-22T20:45:00.000Z"},{"value":0.6378,"timestamp":1792702200,"date":"2026-10-22T20:50:00.000Z"},{"value":0.6378,"timestamp":1792702500,"date":"2026-10-22T20:55:00.000Z"},{"value":0.6378,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.6378,"timestamp":1792703100,"date":"2026-10-22T21:05:00.000Z"},{"value":0.6378,"timestamp":1792703400,"date":"2026-10-22T21:10:00.000Z"},{"value":0.6378,"timestamp":1792703700,"date":"2026-10-22T21:15:00.000Z"},{"value":0.6378,"timestamp":1792704000,"date":"2026-10-22T21:20:00.000Z"},{"value":0.6378,"timestamp":1792704300,"date":"2026-10-22T21:25:00.000Z"},{"value":0.6378,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.6378,"timestamp":1792704900,"date":"2026-10-22T21:35:00.000Z"},{"value":0.6378,"timestamp":1792705200,"date":"2026-10-22T21:40:00.000Z"},{"value":0.6378,"timestamp":1792705500,"date":"2026-10-22T21:45:00.000Z"},{"value":0.6378,"timestamp":1792705800,"date":"2026-10-22T21:50:00.000Z"},{"value":0.6378,"timestamp":1792706100,"date":"2026-10-22T21:55:00.000Z"},{"value":0.6378,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.6378,"timestamp":1792706700,"date":"2026-10-22T22:05:00.000Z"},{"value":0.6378,"timestamp":1792707000,"date":"2026-10-22T22:10:00.000Z"},{"value":0.6378,"timestamp":1792707300,"date":"2026-10-22T22:15:00.000Z"},{"value":0.6378,"timestamp":1792707600,"date":"2026-10-22T22:20:00.000Z"},{"value":0.6378,"timestamp":1792707900,"date":"2026-10-22T22:25:00.000Z"},{"value":0.6378,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.6378,"timestamp":1792708500,"date":"2026-10-22T22:35:00.000Z"},{"value":0.6378,"timestamp":1792708800,"date":"2026-10-22T22:40:00.000Z"},{"value":0.6378,"timestamp":1792709100,"date":"2026-10-22T22:45:00.000Z"},{"value":0.6378,"timestamp":1792709400,"date":"2026-10-22T22:50:00.000Z"},{"value":0.6378,"timestamp":1792709700,"date":"2026-10-22T22:55:00.000Z"},{"value":0.6378,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.6378,"timestamp":1792710300,"date":"2026-10-22T23:05:00.000Z"},{"value":0.6378,"timestamp":1792710600,"date":"2026-10-22T23:10:00.000Z"},{"value":0.6378,"timestamp":1792710900,"date":"2026-10-22T23:15:00.000Z"},{"value":0.6378,"timestamp":1792711200,"date":"2026-10-22T23:20:00.000Z"},{"value":0.6378,"timestamp":1792711500,"date":"2026-10-22T23:25:00.000Z"},{"value":0.6378,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.6378,"timestamp":1792712100,"date":"2026-10-22T23:35:00.000Z"},{"value":0.6378,"timestamp":1792712400,"date":"2026-10-22T23:40:00.000Z"},{"value":0.6378,"timestamp":1792712700,"date":"2026-10-22T23:45:00.000Z"},{"value":0.6378,"timestamp":1792713000,"date":"2026-10-22T23:50:00.000Z"},{"value":0.6378,"timestamp":1792713300,"date":"2026-10-22T23:55:00.000Z"},{"value":0.6378,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.6378,"timestamp":1792713900,"date":"2026-10-23T00:05:00.000Z"},{"value":0.6378,"timestamp":1792714200,"date":"2026-10-23T00:10:00.000Z"},{"value":0.6378,"timestamp":1792714500,"date":"2026-10-23T00:15:00.000Z"},{"value":0.6378,"timestamp":1792714800,"date":"2026-10-23T00:20:00.000Z"},{"value":0.6378,"timestamp":1792715100,"date":"2026-10-23T00:25:00.000Z"},{"value":0.6378,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.6378,"timestamp":1792715700,"date":"2026-10-23T00:35:00.000Z"},{"value":0.6378,"timestamp":1792716000,"date":"2026-10-23T00:40:00.000Z"},{"value":0.6378,"timestamp":1792716300,"date":"2026-10-23T00:45:00.000Z"},{"value":0.6378,"timestamp":1792716600,"date":"2026-10-23T00:50:00.000Z"},{"value":0.6378,"timestamp":1792716900,"date":"2026-10-23T00:55:00.000Z"},{"value":0.6378,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.6378,"timestamp":1792717500,"date":"2026-10-23T01:05:00.000Z"},{"value":0.6378,"timestamp":1792717800,"date":"2026-10-23T01:10:00.000Z"},{"value":0.6378,"timestamp":1792718100,"date":"2026-10-23T01:15:00.000Z"},{"value":0.6378,"timestamp":1792718400,"date":"2026-10-23T01:20:00.000Z"},{"value":0.6378,"timestamp":1792718700,"date":"2026-10-23T01:25:00.000Z"},{"value":0.6378,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.6378,"timestamp":1792719300,"date":"2026-10-23T01:35:00.000Z"},{"value":0.6378,"timestamp":1792719600,"date":"2026-10-23T01:40:00.000Z"},{"value":0.6378,"timestamp":1792719900,"date":"2026-10-23T01:45:00.000Z"},{"value":0.6378,"timestamp":1792720200,"date":"2026-10-23T01:50:00.000Z"},{"value":0.6378,"timestamp":1792720500,"date":"2026-10-23T01:55:00.000Z"},{"value":0.6381,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.6381,"timestamp":1792721100,"date":"2026-10-23T02:05:00.000Z"},{"value":0.6381,"timestamp":1792721400,"date":"2026-10-23T02:10:00.000Z"},{"value":0.6381,"timestamp":1792721700,"date":"2026-10-23T02:15:00.000Z"},{"value":0.6381,"timestamp":1792722000,"date":"2026-10-23T02:20:00.000Z"},{"value":0.6381,"timestamp":1792722300,"date":"2026-10-23T02:25:00.000Z"},{"value":0.6381,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.6381,"timestamp":1792722900,"date":"2026-10-23T02:35:00.000Z"},{"value":0.6381,"timestamp":1792723200,"date":"2026-10-23T02:40:00.000Z"},{"value":0.6381,"timestamp":1792723500,"date":"2026-10-23T02:45:00.000Z"},{"value":0.6381,"timestamp":1792723800,"date":"2026-10-23T02:50:00.000Z"},{"value":0.6381,"timestamp":1792724100,"date":"2026-10-23T02:55:00.000Z"},{"value":0.6381,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.6381,"timestamp":1792724700,"date":"2026-10-23T03:05:00.000Z"},{"value":0.6381,"timestamp":1792725000,"date":"2026-10-23T03:10:00.000Z"},{"value":0.6381,"timestamp":1792725300,"date":"2026-10-23T03:15:00.000Z"},{"value":0.6381,"timestamp":1792725600,"date":"2026-10-23T03:20:00.000Z"},{"value":0.6381,"timestamp":1792725900,"date":"2026-10-23T03:25:00.000Z"},{"value":0.6381,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.6381,"timestamp":1792726500,"date":"2026-10-23T03:35:00.000Z"},{"value":0.6381,"timestamp":1792726800,"date":"2026-10-23T03:40:00.000Z"},{"value":0.6381,"timestamp":1792727100,"date":"2026-10-23T03:45:00.000Z"},{"value":0.6381,"timestamp":1792727400,"date":"2026-10-23T03:50:00.000Z"},{"value":0.6381,"timestamp":1792727700,"date":"2026-10-23T03:55:00.000Z"},{"value":0.6381,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.6381,"timestamp":1792728300,"date":"2026-10-23T04:05:00.000Z"},{"value":0.6381,"timestamp":1792728600,"date":"2026-10-23T04:10:00.000Z"},{"value":0.6381,"timestamp":1792728900,"date":"2026-10-23T04:15:00.000Z"},{"value":0.6381,"timestamp":1792729200,"date":"2026-10-23T04:20:00.000Z"},{"value":0.6381,"timestamp":1792729500,"date":"2026-10-23T04:25:00.000Z"},{"value":0.6381,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.6381,"timestamp":1792730100,"date":"2026-10-23T04:35:00.000Z"},{"value":0.6381,"timestamp":1792730400,"date":"2026-10-23T04:40:00.000Z"},{"value":0.6381,"timestamp":1792730700,"date":"2026-10-23T04:45:00.000Z"},{"value":0.6381,"timestamp":1792731000,"date":"2026-10-23T04:50:00.000Z"},{"value":0.6381,"timestamp":1792731300,"date":"2026-10-23T04:55:00.000Z"},{"value":0.6381,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.6381,"timestamp":1792731900,"date":"2026-10-23T05:05:00.000Z"},{"value":0.6381,"timestamp":1792732200,"date":"2026-10-23T05:10:00.000Z"},{"value":0.6381,"timestamp":1792732500,"date":"2026-10-23T05:15:00.000Z"},{"value":0.6381,"timestamp":1792732800,"date":"2026-10-23T05:20:00.000Z"},{"value":0.6381,"timestamp":1792733100,"date":"2026-10-23T05:25:00.000Z"},{"value":0.6381,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.6381,"timestamp":1792733700,"date":"2026-10-23T05:35:00.000Z"},{"value":0.6381,"timestamp":1792734000,"date":"2026-10-23T05:40:00.000Z"},{"value":0.6381,"timestamp":1792734300,"date":"2026-10-23T05:45:00.000Z"},{"value":0.6381,"timestamp":1792734600,"date":"2026-10-23T05:50:00.000Z"},{"value":0.6381,"timestamp":1792734900,"date":"2026-10-23T05:55:00.000Z"},{"value":0.6381,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.6381,"timestamp":1792735500,"date":"2026-10-23T06:05:00.000Z"},{"value":0.6381,"timestamp":1792735800,"date":"2026-10-23T06:10:00.000Z"},{"value":0.6381,"timestamp":1792736100,"date":"2026-10-23T06:15:00.000Z"},{"value":0.6381,"timestamp":1792736400,"date":"2026-10-23T06:20:00.000Z"},{"value":0.6381,"timestamp":1792736700,"date":"2026-10-23T06:25:00.000Z"},{"value":0.6381,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.6381,"timestamp":1792737300,"date":"2026-10-23T06:35:00.000Z"},{"value":0.6381,"timestamp":1792737600,"date":"2026-10-23T06:40:00.000Z"},{"value":0.6381,"timestamp":1792737900,"date":"2026-10-23T06:45:00.000Z"},{"value":0.6381,"timestamp":1792738200,"date":"2026-10-23T06:50:00.000Z"},{"value":0.6381,"timestamp":1792738500,"date":"2026-10-23T06:55:00.000Z"},{"value":0.6381,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.6381,"timestamp":1792739100,"date":"2026-10-23T07:05:00.000Z"},{"value":0.6381,"timestamp":1792739400,"date":"2026-10-23T07:10:00.000Z"},{"value":0.6381,"timestamp":1792739700,"date":"2026-10-23T07:15:00.000Z"},{"value":0.6381,"timestamp":1792740000,"date":"2026-10-23T07:20:00.000Z"},{"value":0.6381,"timestamp":1792740300,"date":"2026-10-23T07:25:00.000Z"},{"value":0.6381,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.6381,"timestamp":1792740900,"date":"2026-10-23T07:35:00.000Z"},{"value":0.6381,"timestamp":1792741200,"date":"2026-10-23T07:40:00.000Z"},{"value":0.6381,"timestamp":1792741500,"date":"2026-10-23T07:45:00.000Z"},{"value":0.6381,"timestamp":1792741800,"date":"2026-10-23T07:50:00.000Z"},{"value":0.6381,"timestamp":1792742100,"date":"2026-10-23T07:55:00.000Z"},{"value":0.6381,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.6381,"timestamp":1792742700,"date":"2026-10-23T08:05:00.000Z"},{"value":0.6381,"timestamp":1792743000,"date":"2026-10-23T08:10:00.000Z"},{"value":0.6381,"timestamp":1792743300,"date":"2026-10-23T08:15:00.000Z"},{"value":0.6381,"timestamp":1792743600,"date":"2026-10-23T08:20:00.000Z"},{"value":0.6381,"timestamp":1792743900,"date":"2026-10-23T08:25:00.000Z"},{"value":0.6381,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.6381,"timestamp":1792744500,"date":"2026-10-23T08:35:00.000Z"},{"value":0.6381,"timestamp":1792744800,"date":"2026-10-23T08:40:00.000Z"},{"value":0.6381,"timestamp":1792745100,"date":"2026-10-23T08:45:00.000Z"},{"value":0.6381,"timestamp":1792745400,"date":"2026-10-23T08:50:00.000Z"},{"value":0.6381,"timestamp":1792745700,"date":"2026-10-23T08:55:00.000Z"},{"value":0.6381,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.6381,"timestamp":1792746300,"date":"2026-10-23T09:05:00.000Z"},{"value":0.6381,"timestamp":1792746600,"date":"2026-10-23T09:10:00.000Z"},{"value":0.6381,"timestamp":1792746900,"date":"2026-10-23T09:15:00.000Z"},{"value":0.6381,"timestamp":1792747200,"date":"2026-10-23T09:20:00.000Z"},{"value":0.6381,"timestamp":1792747500,"date":"2026-10-23T09:25:00.000Z"},{"value":0.6381,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.6381,"timestamp":1792748100,"date":"2026-10-23T09:35:00.000Z"},{"value":0.6381,"timestamp":1792748400,"date":"2026-10-23T09:40:00.000Z"},{"value":0.6381,"timestamp":1792748700,"date":"2026-10-23T09:45:00.000Z"},{"value":0.6381,"timestamp":1792749000,"date":"2026-10-23T09:50:00.000Z"},{"value":0.6381,"timestamp":1792749300,"date":"2026-10-23T09:55:00.000Z"},{"value":0.6381,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.6381,"timestamp":1792749900,"date":"2026-10-23T10:05:00.000Z"},{"value":0.6381,"timestamp":1792750200,"date":"2026-10-23T10:10:00.000Z"},{"value":0.6381,"timestamp":1792750500,"date":"2026-10-23T10:15:00.000Z"},{"value":0.6381,"timestamp":1792750800,"date":"2026-10-23T10:20:00.000Z"},{"value":0.6381,"timestamp":1792751100,"date":"2026-10-23T10:25:00.000Z"},{"value":0.6378,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.6384,"timestamp":1792751700,"date":"2026-10-23T10:35:00.000Z"},{"value":0.6378,"timestamp":1792752000,"date":"2026-10-23T10:40:00.000Z"},{"value":0.6382,"timestamp":1792752300,"date":"2026-10-23T10:45:00.000Z"},{"value":0.6378,"timestamp":1792752600,"date":"2026-10-23T10:50:00.000Z"},{"value":0.639,"timestamp":1792752900,"date":"2026-10-23T10:55:00.000Z"},{"value":0.6378,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.6381,"timestamp":1792753500,"date":"2026-10-23T11:05:00.000Z"},{"value":0.6381,"timestamp":1792753800,"date":"2026-10-23T11:10:00.000Z"},{"value":0.6413,"timestamp":1792754100,"date":"2026-10-23T11:15:00.000Z"},{"value":0.6402,"timestamp":1792754400,"date":"2026-10-23T11:20:00.000Z"},{"value":0.6385,"timestamp":1792754700,"date":"2026-10-23T11:25:00.000Z"},{"value":0.641,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.6407,"timestamp":1792755300,"date":"2026-10-23T11:35:00.000Z"},{"value":0.6388,"timestamp":1792755600,"date":"2026-10-23T11:40:00.000Z"},{"value":0.64,"timestamp":1792755900,"date":"2026-10-23T11:45:00.000Z"},{"value":0.6388,"timestamp":1792756200,"date":"2026-10-23T11:50:00.000Z"},{"value":0.638,"timestamp":1792756500,"date":"2026-10-23T11:55:00.000Z"},{"value":0.6505,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.6505,"timestamp":1792757100,"date":"2026-10-23T12:05:00.000Z"},{"value":0.6552,"timestamp":1792757400,"date":"2026-10-23T12:10:00.000Z"},{"value":0.6513,"timestamp":1792757700,"date":"2026-10-23T12:15:00.000Z"},{"value":0.6538,"timestamp":1792758000,"date":"2026-10-23T12:20:00.000Z"},{"value":0.6502,"timestamp":1792758300,"date":"2026-10-23T12:25:00.000Z"},{"value":0.6504,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.6501,"timestamp":1792758900,"date":"2026-10-23T12:35:00.000Z"},{"value":0.6504,"timestamp":1792759200,"date":"2026-10-23T12:40:00.000Z"},{"value":0.6504,"timestamp":1792759500,"date":"2026-10-23T12:45:00.000Z"},{"value":0.6503,"timestamp":1792759800,"date":"2026-10-23T12:50:00.000Z"},{"value":0.6483,"timestamp":1792760100,"date":"2026-10-23T12:55:00.000Z"},{"value":0.6534,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.6503,"timestamp":1792760700,"date":"2026-10-23T13:05:00.000Z"},{"value":0.6487,"timestamp":1792761000,"date":"2026-10-23T13:10:00.000Z"},{"value":0.6503,"timestamp":1792761300,"date":"2026-10-23T13:15:00.000Z"},{"value":0.6623,"timestamp":1792761600,"date":"2026-10-23T13:20:00.000Z"},{"value":0.6623,"timestamp":1792761900,"date":"2026-10-23T13:25:00.000Z"},{"value":0.6623,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.6623,"timestamp":1792762500,"date":"2026-10-23T13:35:00.000Z"},{"value":0.6623,"timestamp":1792762800,"date":"2026-10-23T13:40:00.000Z"},{"value":0.6738,"timestamp":1792763100,"date":"2026-10-23T13:45:00.000Z"},{"value":0.6738,"timestamp":1792763400,"date":"2026-10-23T13:50:00.000Z"},{"value":0.6738,"timestamp":1792763700,"date":"2026-10-23T13:55:00.000Z"},{"value":0.6738,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.6738,"timestamp":1792764300,"date":"2026-10-23T14:05:00.000Z"},{"value":0.6738,"timestamp":1792764600,"date":"2026-10-23T14:10:00.000Z"},{"value":0.6738,"timestamp":1792764900,"date":"2026-10-23T14:15:00.000Z"},{"value":0.6738,"timestamp":1792765200,"date":"2026-10-23T14:20:00.000Z"},{"value":0.6738,"timestamp":1792765500,"date":"2026-10-23T14:25:00.000Z"},{"value":0.6738,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.6738,"timestamp":1792766100,"date":"2026-10-23T14:35:00.000Z"},{"value":0.6738,"timestamp":1792766400,"date":"2026-10-23T14:40:00.000Z"},{"value":0.6738,"timestamp":1792766700,"date":"2026-10-23T14:45:00.000Z"},{"value":0.6738,"timestamp":1792767000,"date":"2026-10-23T14:50:00.000Z"},{"value":0.6738,"timestamp":1792767300,"date":"2026-10-23T14:55:00.000Z"},{"value":0.6738,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.6738,"timestamp":1792767900,"date":"2026-10-23T15:05:00.000Z"},{"value":0.6738,"timestamp":1792768200,"date":"2026-10-23T15:10:00.000Z"},{"value":0.6738,"timestamp":1792768500,"date":"2026-10-23T15:15:00.000Z"},{"value":0.6738,"timestamp":1792768800,"date":"2026-10-23T15:20:00.000Z"},{"value":0.6738,"timestamp":1792769100,"date":"2026-10-23T15:25:00.000Z"},{"value":0.6738,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"},{"value":0.6738,"timestamp":1792769700,"date":"2026-10-23T15:35:00.000Z"},{"value":0.6738,"timestamp":1792770000,"date":"2026-10-23T15:40:00.000Z"},{"value":0.6733,"timestamp":1792770300,"date":"2026-10-23T15:45:00.000Z"},{"value":0.6733,"timestamp":1792770600,"date":"2026-10-23T15:50:00.000Z"},{"value":0.6735,"timestamp":1792770900,"date":"2026-10-23T15:55:00.000Z"}],"change_percent":0.07057701478302336},{"timeframe":"7d","prices":[{"value":0.6355,"timestamp":1792171800,"date":"2026-10-16T17:30:00.000Z"},{"value":0.6355,"timestamp":1792173600,"date":"2026-10-16T18:00:00.000Z"},{"value":0.6355,"timestamp":1792175400,"date":"2026-10-16T18:30:00.000Z"},{"value":0.6355,"timestamp":1792177200,"date":"2026-10-16T19:00:00.000Z"},{"value":0.6355,"timestamp":1792179000,"date":"2026-10-16T19:30:00.000Z"},{"value":0.6355,"timestamp":1792180800,"date":"2026-10-16T20:00:00.000Z"},{"value":0.6355,"timestamp":1792182600,"date":"2026-10-16T20:30:00.000Z"},{"value":0.6355,"timestamp":1792184400,"date":"2026-10-16T21:00:00.000Z"},{"value":0.6355,"timestamp":1792186200,"date":"2026-10-16T21:30:00.000Z"},{"value":0.6355,"timestamp":1792188000,"date":"2026-10-16T22:00:00.000Z"},{"value":0.6355,"timestamp":1792189800,"date":"2026-10-16T22:30:00.000Z"},{"value":0.6355,"timestamp":1792191600,"date":"2026-10-16T23:00:00.000Z"},{"value":0.6355,"timestamp":1792193400,"date":"2026-10-16T23:30:00.000Z"},{"value":0.6355,"timestamp":1792195200,"date":"2026-10-17T00:00:00.000Z"},{"value":0.6355,"timestamp":1792197000,"date":"2026-10-17T00:30:00.000Z"},{"value":0.6355,"timestamp":1792198800,"date":"2026-10-17T01:00:00.000Z"},{"value":0.6355,"timestamp":1792200600,"date":"2026-10-17T01:30:00.000Z"},{"value":0.6355,"timestamp":1792202400,"date":"2026-10-17T02:00:00.000Z"},{"value":0.6355,"timestamp":1792204200,"date":"2026-10-17T02:30:00.000Z"},{"value":0.6355,"timestamp":1792206000,"date":"2026-10-17T03:00:00.000Z"},{"value":0.6355,"timestamp":1792207800,"date":"2026-10-17T03:30:00.000Z"},{"value":0.6355,"timestamp":1792209600,"date":"2026-10-17T04:00:00.000Z"},{"value":0.6355,"timestamp":1792211400,"date":"2026-10-17T04:30:00.000Z"},{"value":0.6355,"timestamp":1792213200,"date":"2026-10-17T05:00:00.000Z"},{"value":0.6355,"timestamp":1792215000,"date":"2026-10-17T05:30:00.000Z"},{"value":0.6355,"timestamp":1792216800,"date":"2026-10-17T06:00:00.000Z"},{"value":0.6355,"timestamp":1792218600,"date":"2026-10-17T06:30:00.000Z"},{"value":0.6355,"timestamp":1792220400,"date":"2026-10-17T07:00:00.000Z"},{"value":0.6355,"timestamp":1792222200,"date":"2026-10-17T07:30:00.000Z"},{"value":0.6355,"timestamp":1792224000,"date":"2026-10-17T08:00:00.000Z"},{"value":0.6355,"timestamp":1792225800,"date":"2026-10-17T08:30:00.000Z"},{"value":0.6355,"timestamp":1792227600,"date":"2026-10-17T09:00:00.000Z"},{"value":0.6355,"timestamp":1792229400,"date":"2026-10-17T09:30:00.000Z"},{"value":0.6355,"timestamp":1792231200,"date":"2026-10-17T10:00:00.000Z"},{"value":0.6355,"timestamp":1792233000,"date":"2026-10-17T10:30:00.000Z"},{"value":0.6355,"timestamp":1792234800,"date":"2026-10-17T11:00:00.000Z"},{"value":0.6355,"timestamp":1792236600,"date":"2026-10-17T11:30:00.000Z"},{"value":0.6355,"timestamp":1792238400,"date":"2026-10-17T12:00:00.000Z"},{"value":0.6355,"timestamp":1792240200,"date":"2026-10-17T12:30:00.000Z"},{"value":0.6355,"timestamp":1792242000,"date":"2026-10-17T13:00:00.000Z"},{"value":0.6355,"timestamp":1792243800,"date":"2026-10-17T13:30:00.000Z"},{"value":0.6355,"timestamp":1792245600,"date":"2026-10-17T14:00:00.000Z"},{"value":0.6355,"timestamp":1792247400,"date":"2026-10-17T14:30:00.000Z"},{"value":0.6355,"timestamp":1792249200,"date":"2026-10-17T15:00:00.000Z"},{"value":0.6355,"timestamp":1792251000,"date":"2026-10-17T15:30:00.000Z"},{"value":0.6355,"timestamp":1792252800,"date":"2026-10-17T16:00:00.000Z"},{"value":0.6355,"timestamp":1792254600,"date":"2026-10-17T16:30:00.000Z"},{"value":0.6355,"timestamp":1792256400,"date":"2026-10-17T17:00:00.000Z"},{"value":0.6355,"timestamp":1792258200,"date":"2026-10-17T17:30:00.000Z"},{"value":0.6355,"timestamp":1792260000,"date":"2026-10-17T18:00:00.000Z"},{"value":0.6355,"timestamp":1792261800,"date":"2026-10-17T18:30:00.000Z"},{"value":0.6355,"timestamp":1792263600,"date":"2026-10-17T19:00:00.000Z"},{"value":0.6355,"timestamp":1792265400,"date":"2026-10-17T19:30:00.000Z"},{"value":0.6355,"timestamp":1792267200,"date":"2026-10-17T20:00:00.000Z"},{"value":0.6355,"timestamp":1792269000,"date":"2026-10-17T20:30:00.000Z"},{"value":0.6355,"timestamp":1792270800,"date":"2026-10-17T21:00:00.000Z"},{"value":0.6355,"timestamp":1792272600,"date":"2026-10-17T21:30:00.000Z"},{"value":0.6355,"timestamp":1792274400,"date":"2026-10-17T22:00:00.000Z"},{"value":0.6355,"timestamp":1792276200,"date":"2026-10-17T22:30:00.000Z"},{"value":0.6355,"timestamp":1792278000,"date":"2026-10-17T23:00:00.000Z"},{"value":0.6355,"timestamp":1792279800,"date":"2026-10-17T23:30:00.000Z"},{"value":0.6355,"timestamp":1792281600,"date":"2026-10-18T00:00:00.000Z"},{"value":0.6355,"timestamp":1792283400,"date":"2026-10-18T00:30:00.000Z"},{"value":0.6355,"timestamp":1792285200,"date":"2026-10-18T01:00:00.000Z"},{"value":0.6355,"timestamp":1792287000,"date":"2026-10-18T01:30:00.000Z"},{"value":0.6355,"timestamp":1792288800,"date":"2026-10-18T02:00:00.000Z"},{"value":0.6355,"timestamp":1792290600,"date":"2026-10-18T02:30:00.000Z"},{"value":0.6355,"timestamp":1792292400,"date":"2026-10-18T03:00:00.000Z"},{"value":0.6355,"timestamp":1792294200,"date":"2026-10-18T03:30:00.000Z"},{"value":0.6355,"timestamp":1792296000,"date":"2026-10-18T04:00:00.000Z"},{"value":0.6355,"timestamp":1792297800,"date":"2026-10-18T04:30:00.000Z"},{"value":0.6355,"timestamp":1792299600,"date":"2026-10-18T05:00:00.000Z"},{"value":0.6355,"timestamp":1792301400,"date":"2026-10-18T05:30:00.000Z"},{"value":0.6355,"timestamp":1792303200,"date":"2026-10-18T06:00:00.000Z"},{"value":0.6355,"timestamp":1792305000,"date":"2026-10-18T06:30:00.000Z"},{"value":0.6355,"timestamp":1792306800,"date":"2026-10-18T07:00:00.000Z"},{"value":0.6355,"timestamp":1792308600,"date":"2026-10-18T07:30:00.000Z"},{"value":0.6355,"timestamp":1792310400,"date":"2026-10-18T08:00:00.000Z"},{"value":0.6355,"timestamp":1792312200,"date":"2026-10-18T08:30:00.000Z"},{"value":0.6355,"timestamp":1792314000,"date":"2026-10-18T09:00:00.000Z"},{"value":0.6355,"timestamp":1792315800,"date":"2026-10-18T09:30:00.000Z"},{"value":0.6355,"timestamp":1792317600,"date":"2026-10-18T10:00:00.000Z"},{"value":0.6355,"timestamp":1792319400,"date":"2026-10-18T10:30:00.000Z"},{"value":0.6355,"timestamp":1792321200,"date":"2026-10-18T11:00:00.000Z"},{"value":0.6355,"timestamp":1792323000,"date":"2026-10-18T11:30:00.000Z"},{"value":0.6355,"timestamp":1792324800,"date":"2026-10-18T12:00:00.000Z"},{"value":0.6355,"timestamp":1792326600,"date":"2026-10-18T12:30:00.000Z"},{"value":0.6355,"timestamp":1792328400,"date":"2026-10-18T13:00:00.000Z"},{"value":0.6355,"timestamp":1792330200,"date":"2026-10-18T13:30:00.000Z"},{"value":0.6355,"timestamp":1792332000,"date":"2026-10-18T14:00:00.000Z"},{"value":0.6355,"timestamp":1792333800,"date":"2026-10-18T14:30:00.000Z"},{"value":0.6355,"timestamp":1792335600,"date":"2026-10-18T15:00:00.000Z"},{"value":0.6355,"timestamp":1792337400,"date":"2026-10-18T15:30:00.000Z"},{"value":0.6355,"timestamp":1792339200,"date":"2026-10-18T16:00:00.000Z"},{"value":0.6355,"timestamp":1792341000,"date":"2026-10-18T16:30:00.000Z"},{"value":0.6355,"timestamp":1792342800,"date":"2026-10-18T17:00:00.000Z"},{"value":0.6355,"timestamp":1792344600,"date":"2026-10-18T17:30:00.000Z"},{"value":0.6355,"timestamp":1792346400,"date":"2026-10-18T18:00:00.000Z"},{"value":0.6355,"timestamp":1792348200,"date":"2026-10-18T18:30:00.000Z"},{"value":0.6355,"timestamp":1792350000,"date":"2026-10-18T19:00:00.000Z"},{"value":0.6355,"timestamp":1792351800,"date":"2026-10-18T19:30:00.000Z"},{"value":0.6355,"timestamp":1792353600,"date":"2026-10-18T20:00:00.000Z"},{"value":0.6355,"timestamp":1792355400,"date":"2026-10-18T20:30:00.000Z"},{"value":0.6355,"timestamp":1792357200,"date":"2026-10-18T21:00:00.000Z"},{"value":0.6355,"timestamp":1792359000,"date":"2026-10-18T21:30:00.000Z"},{"value":0.6355,"timestamp":1792360800,"date":"2026-10-18T22:00:00.000Z"},{"value":0.6355,"timestamp":1792362600,"date":"2026-10-18T22:30:00.000Z"},{"value":0.6355,"timestamp":1792364400,"date":"2026-10-18T23:00:00.000Z"},{"value":0.6355,"timestamp":1792366200,"date":"2026-10-18T23:30:00.000Z"},{"value":0.6355,"timestamp":1792368000,"date":"2026-10-19T00:00:00.000Z"},{"value":0.6337,"timestamp":1792369800,"date":"2026-10-19T00:30:00.000Z"},{"value":0.6355,"timestamp":1792371600,"date":"2026-10-19T01:00:00.000Z"},{"value":0.6337,"timestamp":1792373400,"date":"2026-10-19T01:30:00.000Z"},{"value":0.6355,"timestamp":1792375200,"date":"2026-10-19T02:00:00.000Z"},{"value":0.6336,"timestamp":1792377000,"date":"2026-10-19T02:30:00.000Z"},{"value":0.6336,"timestamp":1792378800,"date":"2026-10-19T03:00:00.000Z"},{"value":0.6355,"timestamp":1792380600,"date":"2026-10-19T03:30:00.000Z"},{"value":0.6334,"timestamp":1792382400,"date":"2026-10-19T04:00:00.000Z"},{"value":0.6333,"timestamp":1792384200,"date":"2026-10-19T04:30:00.000Z"},{"value":0.6331,"timestamp":1792386000,"date":"2026-10-19T05:00:00.000Z"},{"value":0.6328,"timestamp":1792387800,"date":"2026-10-19T05:30:00.000Z"},{"value":0.6351,"timestamp":1792389600,"date":"2026-10-19T06:00:00.000Z"},{"value":0.6351,"timestamp":1792391400,"date":"2026-10-19T06:30:00.000Z"},{"value":0.6351,"timestamp":1792393200,"date":"2026-10-19T07:00:00.000Z"},{"value":0.6351,"timestamp":1792395000,"date":"2026-10-19T07:30:00.000Z"},{"value":0.6351,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.6351,"timestamp":1792398600,"date":"2026-10-19T08:30:00.000Z"},{"value":0.6351,"timestamp":1792400400,"date":"2026-10-19T09:00:00.000Z"},{"value":0.6328,"timestamp":1792402200,"date":"2026-10-19T09:30:00.000Z"},{"value":0.6327,"timestamp":1792404000,"date":"2026-10-19T10:00:00.000Z"},{"value":0.6349,"timestamp":1792405800,"date":"2026-10-19T10:30:00.000Z"},{"value":0.6349,"timestamp":1792407600,"date":"2026-10-19T11:00:00.000Z"},{"value":0.6349,"timestamp":1792409400,"date":"2026-10-19T11:30:00.000Z"},{"value":0.6349,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.6326,"timestamp":1792413000,"date":"2026-10-19T12:30:00.000Z"},{"value":0.6326,"timestamp":1792414800,"date":"2026-10-19T13:00:00.000Z"},{"value":0.6348,"timestamp":1792416600,"date":"2026-10-19T13:30:00.000Z"},{"value":0.6348,"timestamp":1792418400,"date":"2026-10-19T14:00:00.000Z"},{"value":0.6348,"timestamp":1792420200,"date":"2026-10-19T14:30:00.000Z"},{"value":0.6348,"timestamp":1792422000,"date":"2026-10-19T15:00:00.000Z"},{"value":0.6348,"timestamp":1792423800,"date":"2026-10-19T15:30:00.000Z"},{"value":0.6348,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.6348,"timestamp":1792427400,"date":"2026-10-19T16:30:00.000Z"},{"value":0.6348,"timestamp":1792429200,"date":"2026-10-19T17:00:00.000Z"},{"value":0.6348,"timestamp":1792431000,"date":"2026-10-19T17:30:00.000Z"},{"value":0.6348,"timestamp":1792432800,"date":"2026-10-19T18:00:00.000Z"},{"value":0.6348,"timestamp":1792434600,"date":"2026-10-19T18:30:00.000Z"},{"value":0.6348,"timestamp":1792436400,"date":"2026-10-19T19:00:00.000Z"},{"value":0.6348,"timestamp":1792438200,"date":"2026-10-19T19:30:00.000Z"},{"value":0.6348,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.6348,"timestamp":1792441800,"date":"2026-10-19T20:30:00.000Z"},{"value":0.6348,"timestamp":1792443600,"date":"2026-10-19T21:00:00.000Z"},{"value":0.6348,"timestamp":1792445400,"date":"2026-10-19T21:30:00.000Z"},{"value":0.6348,"timestamp":1792447200,"date":"2026-10-19T22:00:00.000Z"},{"value":0.6348,"timestamp":1792449000,"date":"2026-10-19T22:30:00.000Z"},{"value":0.6348,"timestamp":1792450800,"date":"2026-10-19T23:00:00.000Z"},{"value":0.6326,"timestamp":1792452600,"date":"2026-10-19T23:30:00.000Z"},{"value":0.6327,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.6327,"timestamp":1792456200,"date":"2026-10-20T00:30:00.000Z"},{"value":0.6327,"timestamp":1792458000,"date":"2026-10-20T01:00:00.000Z"},{"value":0.6327,"timestamp":1792459800,"date":"2026-10-20T01:30:00.000Z"},{"value":0.6327,"timestamp":1792461600,"date":"2026-10-20T02:00:00.000Z"},{"value":0.6327,"timestamp":1792463400,"date":"2026-10-20T02:30:00.000Z"},{"value":0.6327,"timestamp":1792465200,"date":"2026-10-20T03:00:00.000Z"},{"value":0.6327,"timestamp":1792467000,"date":"2026-10-20T03:30:00.000Z"},{"value":0.6327,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.6327,"timestamp":1792470600,"date":"2026-10-20T04:30:00.000Z"},{"value":0.6327,"timestamp":1792472400,"date":"2026-10-20T05:00:00.000Z"},{"value":0.6327,"timestamp":1792474200,"date":"2026-10-20T05:30:00.000Z"},{"value":0.6327,"timestamp":1792476000,"date":"2026-10-20T06:00:00.000Z"},{"value":0.6327,"timestamp":1792477800,"date":"2026-10-20T06:30:00.000Z"},{"value":0.6327,"timestamp":1792479600,"date":"2026-10-20T07:00:00.000Z"},{"value":0.6327,"timestamp":1792481400,"date":"2026-10-20T07:30:00.000Z"},{"value":0.6327,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.6327,"timestamp":1792485000,"date":"2026-10-20T08:30:00.000Z"},{"value":0.6327,"timestamp":1792486800,"date":"2026-10-20T09:00:00.000Z"},{"value":0.6327,"timestamp":1792488600,"date":"2026-10-20T09:30:00.000Z"},{"value":0.6327,"timestamp":1792490400,"date":"2026-10-20T10:00:00.000Z"},{"value":0.6454,"timestamp":1792492200,"date":"2026-10-20T10:30:00.000Z"},{"value":0.6454,"timestamp":1792494000,"date":"2026-10-20T11:00:00.000Z"},{"value":0.6454,"timestamp":1792495800,"date":"2026-10-20T11:30:00.000Z"},{"value":0.6454,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.6475,"timestamp":1792499400,"date":"2026-10-20T12:30:00.000Z"},{"value":0.6475,"timestamp":1792501200,"date":"2026-10-20T13:00:00.000Z"},{"value":0.6475,"timestamp":1792503000,"date":"2026-10-20T13:30:00.000Z"},{"value":0.6454,"timestamp":1792504800,"date":"2026-10-20T14:00:00.000Z"},{"value":0.6266,"timestamp":1792506600,"date":"2026-10-20T14:30:00.000Z"},{"value":0.6266,"timestamp":1792508400,"date":"2026-10-20T15:00:00.000Z"},{"value":0.6266,"timestamp":1792510200,"date":"2026-10-20T15:30:00.000Z"},{"value":0.6266,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.6266,"timestamp":1792513800,"date":"2026-10-20T16:30:00.000Z"},{"value":0.6266,"timestamp":1792515600,"date":"2026-10-20T17:00:00.000Z"},{"value":0.6266,"timestamp":1792517400,"date":"2026-10-20T17:30:00.000Z"},{"value":0.6266,"timestamp":1792519200,"date":"2026-10-20T18:00:00.000Z"},{"value":0.6266,"timestamp":1792521000,"date":"2026-10-20T18:30:00.000Z"},{"value":0.6266,"timestamp":1792522800,"date":"2026-10-20T19:00:00.000Z"},{"value":0.6266,"timestamp":1792524600,"date":"2026-10-20T19:30:00.000Z"},{"value":0.6266,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.6266,"timestamp":1792528200,"date":"2026-10-20T20:30:00.000Z"},{"value":0.6266,"timestamp":1792530000,"date":"2026-10-20T21:00:00.000Z"},{"value":0.6266,"timestamp":1792531800,"date":"2026-10-20T21:30:00.000Z"},{"value":0.6266,"timestamp":1792533600,"date":"2026-10-20T22:00:00.000Z"},{"value":0.6266,"timestamp":1792535400,"date":"2026-10-20T22:30:00.000Z"},{"value":0.6266,"timestamp":1792537200,"date":"2026-10-20T23:00:00.000Z"},{"value":0.6266,"timestamp":1792539000,"date":"2026-10-20T23:30:00.000Z"},{"value":0.6266,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.6266,"timestamp":1792542600,"date":"2026-10-21T00:30:00.000Z"},{"value":0.6266,"timestamp":1792544400,"date":"2026-10-21T01:00:00.000Z"},{"value":0.6266,"timestamp":1792546200,"date":"2026-10-21T01:30:00.000Z"},{"value":0.6266,"timestamp":1792548000,"date":"2026-10-21T02:00:00.000Z"},{"value":0.6266,"timestamp":1792549800,"date":"2026-10-21T02:30:00.000Z"},{"value":0.6266,"timestamp":1792551600,"date":"2026-10-21T03:00:00.000Z"},{"value":0.6266,"timestamp":1792553400,"date":"2026-10-21T03:30:00.000Z"},{"value":0.6266,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.6266,"timestamp":1792557000,"date":"2026-10-21T04:30:00.000Z"},{"value":0.6266,"timestamp":1792558800,"date":"2026-10-21T05:00:00.000Z"},{"value":0.6266,"timestamp":1792560600,"date":"2026-10-21T05:30:00.000Z"},{"value":0.6266,"timestamp":1792562400,"date":"2026-10-21T06:00:00.000Z"},{"value":0.6266,"timestamp":1792564200,"date":"2026-10-21T06:30:00.000Z"},{"value":0.6266,"timestamp":1792566000,"date":"2026-10-21T07:00:00.000Z"},{"value":0.6266,"timestamp":1792567800,"date":"2026-10-21T07:30:00.000Z"},{"value":0.6266,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.6266,"timestamp":1792571400,"date":"2026-10-21T08:30:00.000Z"},{"value":0.6112,"timestamp":1792573200,"date":"2026-10-21T09:00:00.000Z"},{"value":0.6112,"timestamp":1792575000,"date":"2026-10-21T09:30:00.000Z"},{"value":0.6112,"timestamp":1792576800,"date":"2026-10-21T10:00:00.000Z"},{"value":0.6112,"timestamp":1792578600,"date":"2026-10-21T10:30:00.000Z"},{"value":0.6112,"timestamp":1792580400,"date":"2026-10-21T11:00:00.000Z"},{"value":0.6112,"timestamp":1792582200,"date":"2026-10-21T11:30:00.000Z"},{"value":0.6112,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.6112,"timestamp":1792585800,"date":"2026-10-21T12:30:00.000Z"},{"value":0.6112,"timestamp":1792587600,"date":"2026-10-21T13:00:00.000Z"},{"value":0.6112,"timestamp":1792589400,"date":"2026-10-21T13:30:00.000Z"},{"value":0.6112,"timestamp":1792591200,"date":"2026-10-21T14:00:00.000Z"},{"value":0.6112,"timestamp":1792593000,"date":"2026-10-21T14:30:00.000Z"},{"value":0.6249,"timestamp":1792594800,"date":"2026-10-21T15:00:00.000Z"},{"value":0.6249,"timestamp":1792596600,"date":"2026-10-21T15:30:00.000Z"},{"value":0.6249,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.6249,"timestamp":1792600200,"date":"2026-10-21T16:30:00.000Z"},{"value":0.6249,"timestamp":1792602000,"date":"2026-10-21T17:00:00.000Z"},{"value":0.6249,"timestamp":1792603800,"date":"2026-10-21T17:30:00.000Z"},{"value":0.6249,"timestamp":1792605600,"date":"2026-10-21T18:00:00.000Z"},{"value":0.6249,"timestamp":1792607400,"date":"2026-10-21T18:30:00.000Z"},{"value":0.6249,"timestamp":1792609200,"date":"2026-10-21T19:00:00.000Z"},{"value":0.6249,"timestamp":1792611000,"date":"2026-10-21T19:30:00.000Z"},{"value":0.6249,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.6249,"timestamp":1792614600,"date":"2026-10-21T20:30:00.000Z"},{"value":0.6249,"timestamp":1792616400,"date":"2026-10-21T21:00:00.000Z"},{"value":0.6249,"timestamp":1792618200,"date":"2026-10-21T21:30:00.000Z"},{"value":0.6249,"timestamp":1792620000,"date":"2026-10-21T22:00:00.000Z"},{"value":0.6249,"timestamp":1792621800,"date":"2026-10-21T22:30:00.000Z"},{"value":0.6249,"timestamp":1792623600,"date":"2026-10-21T23:00:00.000Z"},{"value":0.6249,"timestamp":1792625400,"date":"2026-10-21T23:30:00.000Z"},{"value":0.6249,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.6249,"timestamp":1792629000,"date":"2026-10-22T00:30:00.000Z"},{"value":0.6249,"timestamp":1792630800,"date":"2026-10-22T01:00:00.000Z"},{"value":0.6249,"timestamp":1792632600,"date":"2026-10-22T01:30:00.000Z"},{"value":0.6249,"timestamp":1792634400,"date":"2026-10-22T02:00:00.000Z"},{"value":0.6249,"timestamp":1792636200,"date":"2026-10-22T02:30:00.000Z"},{"value":0.6249,"timestamp":1792638000,"date":"2026-10-22T03:00:00.000Z"},{"value":0.6249,"timestamp":1792639800,"date":"2026-10-22T03:30:00.000Z"},{"value":0.6249,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.6249,"timestamp":1792643400,"date":"2026-10-22T04:30:00.000Z"},{"value":0.6249,"timestamp":1792645200,"date":"2026-10-22T05:00:00.000Z"},{"value":0.6249,"timestamp":1792647000,"date":"2026-10-22T05:30:00.000Z"},{"value":0.6249,"timestamp":1792648800,"date":"2026-10-22T06:00:00.000Z"},{"value":0.6249,"timestamp":1792650600,"date":"2026-10-22T06:30:00.000Z"},{"value":0.6249,"timestamp":1792652400,"date":"2026-10-22T07:00:00.000Z"},{"value":0.6292,"timestamp":1792654200,"date":"2026-10-22T07:30:00.000Z"},{"value":0.6292,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.6292,"timestamp":1792657800,"date":"2026-10-22T08:30:00.000Z"},{"value":0.6292,"timestamp":1792659600,"date":"2026-10-22T09:00:00.000Z"},{"value":0.6292,"timestamp":1792661400,"date":"2026-10-22T09:30:00.000Z"},{"value":0.6292,"timestamp":1792663200,"date":"2026-10-22T10:00:00.000Z"},{"value":0.6292,"timestamp":1792665000,"date":"2026-10-22T10:30:00.000Z"},{"value":0.6292,"timestamp":1792666800,"date":"2026-10-22T11:00:00.000Z"},{"value":0.6292,"timestamp":1792668600,"date":"2026-10-22T11:30:00.000Z"},{"value":0.6292,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.6292,"timestamp":1792672200,"date":"2026-10-22T12:30:00.000Z"},{"value":0.6292,"timestamp":1792674000,"date":"2026-10-22T13:00:00.000Z"},{"value":0.6292,"timestamp":1792675800,"date":"2026-10-22T13:30:00.000Z"},{"value":0.6292,"timestamp":1792677600,"date":"2026-10-22T14:00:00.000Z"},{"value":0.6292,"timestamp":1792679400,"date":"2026-10-22T14:30:00.000Z"},{"value":0.6292,"timestamp":1792681200,"date":"2026-10-22T15:00:00.000Z"},{"value":0.6292,"timestamp":1792683000,"date":"2026-10-22T15:30:00.000Z"},{"value":0.6292,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.6378,"timestamp":1792686600,"date":"2026-10-22T16:30:00.000Z"},{"value":0.6378,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.6378,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.6378,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.6378,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.6378,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.6378,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.6378,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.6378,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.6378,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.6378,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.6378,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.6378,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.6378,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.6378,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.6378,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.6378,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.6378,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.6378,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.6381,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.6381,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.6381,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.6381,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.6381,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.6381,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.6381,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.6381,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.6381,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.6381,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.6381,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.6381,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.6381,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.6381,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.6381,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.6381,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.6381,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.6378,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.6385,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.638,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.6502,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.6483,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.6503,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.6738,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.6738,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.6738,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.6738,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.6735,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"}],"change_percent":0.05979543666404412},{"timeframe":"30d","prices":[{"value":0.6355,"timestamp":1792166400,"date":"2026-10-16T16:00:00.000Z"},{"value":0.6355,"timestamp":1792180800,"date":"2026-10-16T20:00:00.000Z"},{"value":0.6355,"timestamp":1792195200,"date":"2026-10-17T00:00:00.000Z"},{"value":0.6355,"timestamp":1792209600,"date":"2026-10-17T04:00:00.000Z"},{"value":0.6355,"timestamp":1792224000,"date":"2026-10-17T08:00:00.000Z"},{"value":0.6355,"timestamp":1792238400,"date":"2026-10-17T12:00:00.000Z"},{"value":0.6355,"timestamp":1792252800,"date":"2026-10-17T16:00:00.000Z"},{"value":0.6355,"timestamp":1792267200,"date":"2026-10-17T20:00:00.000Z"},{"value":0.6355,"timestamp":1792281600,"date":"2026-10-18T00:00:00.000Z"},{"value":0.6355,"timestamp":1792296000,"date":"2026-10-18T04:00:00.000Z"},{"value":0.6355,"timestamp":1792310400,"date":"2026-10-18T08:00:00.000Z"},{"value":0.6355,"timestamp":1792324800,"date":"2026-10-18T12:00:00.000Z"},{"value":0.6355,"timestamp":1792339200,"date":"2026-10-18T16:00:00.000Z"},{"value":0.6355,"timestamp":1792353600,"date":"2026-10-18T20:00:00.000Z"},{"value":0.6355,"timestamp":1792368000,"date":"2026-10-19T00:00:00.000Z"},{"value":0.6351,"timestamp":1792382400,"date":"2026-10-19T04:00:00.000Z"},{"value":0.6349,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.6348,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.6348,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.6326,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.6327,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.6327,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.6454,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.6266,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.6266,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.6266,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.6266,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.6266,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.6112,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.6249,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.6249,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.6249,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.6249,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.6292,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.6292,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.6292,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.6378,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.6378,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.6381,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.6381,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.638,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.6735,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"}],"change_percent":0.05979543666404412},{"timeframe":"all","prices":[{"value":0.6355,"timestamp":1792166400,"date":"2026-10-16T16:00:00.000Z"},{"value":0.6355,"timestamp":1792180800,"date":"2026-10-16T20:00:00.000Z"},{"value":0.6355,"timestamp":1792195200,"date":"2026-10-17T00:00:00.000Z"},{"value":0.6355,"timestamp":1792209600,"date":"2026-10-17T04:00:00.000Z"},{"value":0.6355,"timestamp":1792224000,"date":"2026-10-17T08:00:00.000Z"},{"value":0.6355,"timestamp":1792238400,"date":"2026-10-17T12:00:00.000Z"},{"value":0.6355,"timestamp":1792252800,"date":"2026-10-17T16:00:00.000Z"},{"value":0.6355,"timestamp":1792267200,"date":"2026-10-17T20:00:00.000Z"},{"value":0.6355,"timestamp":1792281600,"date":"2026-10-18T00:00:00.000Z"},{"value":0.6355,"timestamp":1792296000,"date":"2026-10-18T04:00:00.000Z"},{"value":0.6355,"timestamp":1792310400,"date":"2026-10-18T08:00:00.000Z"},{"value":0.6355,"timestamp":1792324800,"date":"2026-10-18T12:00:00.000Z"},{"value":0.6355,"timestamp":1792339200,"date":"2026-10-18T16:00:00.000Z"},{"value":0.6355,"timestamp":1792353600,"date":"2026-10-18T20:00:00.000Z"},{"value":0.6355,"timestamp":1792368000,"date":"2026-10-19T00:00:00.000Z"},{"value":0.6351,"timestamp":1792382400,"date":"2026-10-19T04:00:00.000Z"},{"value":0.6349,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.6348,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.6348,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.6326,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.6327,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.6327,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.6454,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.6266,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.6266,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.6266,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.6266,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.6266,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.6112,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.6249,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.6249,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.6249,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.6249,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.6292,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.6292,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.6292,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.6378,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.6378,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.6381,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.6381,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.638,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.6735,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"}],"change_percent":0.05979543666404412}]},{"id":1,"title":"$55","shares":1949.65658706,"sharesHeld":3329.49373339,"price":0.32628257,"closingPrice":null,"priceChange24h":-0.11940652,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":30,"tokenId":"240947","price_charts":[{"timeframe":"24h","prices":[{"value":0.3709,"timestamp":1792686900,"date":"2026-10-22T16:35:00.000Z"},{"value":0.3745,"timestamp":1792687200,"date":"2026-10-22T16:40:00.000Z"},{"value":0.3622,"timestamp":1792687500,"date":"2026-10-22T16:45:00.000Z"},{"value":0.3622,"timestamp":1792687800,"date":"2026-10-22T16:50:00.000Z"},{"value":0.3622,"timestamp":1792688100,"date":"2026-10-22T16:55:00.000Z"},{"value":0.3622,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.3622,"timestamp":1792688700,"date":"2026-10-22T17:05:00.000Z"},{"value":0.3622,"timestamp":1792689000,"date":"2026-10-22T17:10:00.000Z"},{"value":0.3622,"timestamp":1792689300,"date":"2026-10-22T17:15:00.000Z"},{"value":0.3622,"timestamp":1792689600,"date":"2026-10-22T17:20:00.000Z"},{"value":0.3622,"timestamp":1792689900,"date":"2026-10-22T17:25:00.000Z"},{"value":0.3622,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.3622,"timestamp":1792690500,"date":"2026-10-22T17:35:00.000Z"},{"value":0.3622,"timestamp":1792690800,"date":"2026-10-22T17:40:00.000Z"},{"value":0.3622,"timestamp":1792691100,"date":"2026-10-22T17:45:00.000Z"},{"value":0.3622,"timestamp":1792691400,"date":"2026-10-22T17:50:00.000Z"},{"value":0.3622,"timestamp":1792691700,"date":"2026-10-22T17:55:00.000Z"},{"value":0.3622,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.3622,"timestamp":1792692300,"date":"2026-10-22T18:05:00.000Z"},{"value":0.3622,"timestamp":1792692600,"date":"2026-10-22T18:10:00.000Z"},{"value":0.3622,"timestamp":1792692900,"date":"2026-10-22T18:15:00.000Z"},{"value":0.3622,"timestamp":1792693200,"date":"2026-10-22T18:20:00.000Z"},{"value":0.3622,"timestamp":1792693500,"date":"2026-10-22T18:25:00.000Z"},{"value":0.3622,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.3622,"timestamp":1792694100,"date":"2026-10-22T18:35:00.000Z"},{"value":0.3622,"timestamp":1792694400,"date":"2026-10-22T18:40:00.000Z"},{"value":0.3622,"timestamp":1792694700,"date":"2026-10-22T18:45:00.000Z"},{"value":0.3622,"timestamp":1792695000,"date":"2026-10-22T18:50:00.000Z"},{"value":0.3622,"timestamp":1792695300,"date":"2026-10-22T18:55:00.000Z"},{"value":0.3622,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.3622,"timestamp":1792695900,"date":"2026-10-22T19:05:00.000Z"},{"value":0.3622,"timestamp":1792696200,"date":"2026-10-22T19:10:00.000Z"},{"value":0.3622,"timestamp":1792696500,"date":"2026-10-22T19:15:00.000Z"},{"value":0.3622,"timestamp":1792696800,"date":"2026-10-22T19:20:00.000Z"},{"value":0.3622,"timestamp":1792697100,"date":"2026-10-22T19:25:00.000Z"},{"value":0.3622,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.3622,"timestamp":1792697700,"date":"2026-10-22T19:35:00.000Z"},{"value":0.3622,"timestamp":1792698000,"date":"2026-10-22T19:40:00.000Z"},{"value":0.3622,"timestamp":1792698300,"date":"2026-10-22T19:45:00.000Z"},{"value":0.3622,"timestamp":1792698600,"date":"2026-10-22T19:50:00.000Z"},{"value":0.3622,"timestamp":1792698900,"date":"2026-10-22T19:55:00.000Z"},{"value":0.3622,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.3622,"timestamp":1792699500,"date":"2026-10-22T20:05:00.000Z"},{"value":0.3622,"timestamp":1792699800,"date":"2026-10-22T20:10:00.000Z"},{"value":0.3622,"timestamp":1792700100,"date":"2026-10-22T20:15:00.000Z"},{"value":0.3622,"timestamp":1792700400,"date":"2026-10-22T20:20:00.000Z"},{"value":0.3622,"timestamp":1792700700,"date":"2026-10-22T20:25:00.000Z"},{"value":0.3622,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.3622,"timestamp":1792701300,"date":"2026-10-22T20:35:00.000Z"},{"value":0.3622,"timestamp":1792701600,"date":"2026-10-22T20:40:00.000Z"},{"value":0.3622,"timestamp":1792701900,"date":"2026-10-22T20:45:00.000Z"},{"value":0.3622,"timestamp":1792702200,"date":"2026-10-22T20:50:00.000Z"},{"value":0.3622,"timestamp":1792702500,"date":"2026-10-22T20:55:00.000Z"},{"value":0.3622,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.3622,"timestamp":1792703100,"date":"2026-10-22T21:05:00.000Z"},{"value":0.3622,"timestamp":1792703400,"date":"2026-10-22T21:10:00.000Z"},{"value":0.3622,"timestamp":1792703700,"date":"2026-10-22T21:15:00.000Z"},{"value":0.3622,"timestamp":1792704000,"date":"2026-10-22T21:20:00.000Z"},{"value":0.3622,"timestamp":1792704300,"date":"2026-10-22T21:25:00.000Z"},{"value":0.3622,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.3622,"timestamp":1792704900,"date":"2026-10-22T21:35:00.000Z"},{"value":0.3622,"timestamp":1792705200,"date":"2026-10-22T21:40:00.000Z"},{"value":0.3622,"timestamp":1792705500,"date":"2026-10-22T21:45:00.000Z"},{"value":0.3622,"timestamp":1792705800,"date":"2026-10-22T21:50:00.000Z"},{"value":0.3622,"timestamp":1792706100,"date":"2026-10-22T21:55:00.000Z"},{"value":0.3622,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.3622,"timestamp":1792706700,"date":"2026-10-22T22:05:00.000Z"},{"value":0.3622,"timestamp":1792707000,"date":"2026-10-22T22:10:00.000Z"},{"value":0.3622,"timestamp":1792707300,"date":"2026-10-22T22:15:00.000Z"},{"value":0.3622,"timestamp":1792707600,"date":"2026-10-22T22:20:00.000Z"},{"value":0.3622,"timestamp":1792707900,"date":"2026-10-22T22:25:00.000Z"},{"value":0.3622,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.3622,"timestamp":1792708500,"date":"2026-10-22T22:35:00.000Z"},{"value":0.3622,"timestamp":1792708800,"date":"2026-10-22T22:40:00.000Z"},{"value":0.3622,"timestamp":1792709100,"date":"2026-10-22T22:45:00.000Z"},{"value":0.3622,"timestamp":1792709400,"date":"2026-10-22T22:50:00.000Z"},{"value":0.3622,"timestamp":1792709700,"date":"2026-10-22T22:55:00.000Z"},{"value":0.3622,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.3622,"timestamp":1792710300,"date":"2026-10-22T23:05:00.000Z"},{"value":0.3622,"timestamp":1792710600,"date":"2026-10-22T23:10:00.000Z"},{"value":0.3622,"timestamp":1792710900,"date":"2026-10-22T23:15:00.000Z"},{"value":0.3622,"timestamp":1792711200,"date":"2026-10-22T23:20:00.000Z"},{"value":0.3622,"timestamp":1792711500,"date":"2026-10-22T23:25:00.000Z"},{"value":0.3622,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.3622,"timestamp":1792712100,"date":"2026-10-22T23:35:00.000Z"},{"value":0.3622,"timestamp":1792712400,"date":"2026-10-22T23:40:00.000Z"},{"value":0.3622,"timestamp":1792712700,"date":"2026-10-22T23:45:00.000Z"},{"value":0.3622,"timestamp":1792713000,"date":"2026-10-22T23:50:00.000Z"},{"value":0.3622,"timestamp":1792713300,"date":"2026-10-22T23:55:00.000Z"},{"value":0.3622,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.3622,"timestamp":1792713900,"date":"2026-10-23T00:05:00.000Z"},{"value":0.3622,"timestamp":1792714200,"date":"2026-10-23T00:10:00.000Z"},{"value":0.3622,"timestamp":1792714500,"date":"2026-10-23T00:15:00.000Z"},{"value":0.3622,"timestamp":1792714800,"date":"2026-10-23T00:20:00.000Z"},{"value":0.3622,"timestamp":1792715100,"date":"2026-10-23T00:25:00.000Z"},{"value":0.3622,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.3622,"timestamp":1792715700,"date":"2026-10-23T00:35:00.000Z"},{"value":0.3622,"timestamp":1792716000,"date":"2026-10-23T00:40:00.000Z"},{"value":0.3622,"timestamp":1792716300,"date":"2026-10-23T00:45:00.000Z"},{"value":0.3622,"timestamp":1792716600,"date":"2026-10-23T00:50:00.000Z"},{"value":0.3622,"timestamp":1792716900,"date":"2026-10-23T00:55:00.000Z"},{"value":0.3622,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.3622,"timestamp":1792717500,"date":"2026-10-23T01:05:00.000Z"},{"value":0.3622,"timestamp":1792717800,"date":"2026-10-23T01:10:00.000Z"},{"value":0.3622,"timestamp":1792718100,"date":"2026-10-23T01:15:00.000Z"},{"value":0.3622,"timestamp":1792718400,"date":"2026-10-23T01:20:00.000Z"},{"value":0.3622,"timestamp":1792718700,"date":"2026-10-23T01:25:00.000Z"},{"value":0.3622,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.3622,"timestamp":1792719300,"date":"2026-10-23T01:35:00.000Z"},{"value":0.3622,"timestamp":1792719600,"date":"2026-10-23T01:40:00.000Z"},{"value":0.3622,"timestamp":1792719900,"date":"2026-10-23T01:45:00.000Z"},{"value":0.3622,"timestamp":1792720200,"date":"2026-10-23T01:50:00.000Z"},{"value":0.3622,"timestamp":1792720500,"date":"2026-10-23T01:55:00.000Z"},{"value":0.3619,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.3619,"timestamp":1792721100,"date":"2026-10-23T02:05:00.000Z"},{"value":0.3619,"timestamp":1792721400,"date":"2026-10-23T02:10:00.000Z"},{"value":0.3619,"timestamp":1792721700,"date":"2026-10-23T02:15:00.000Z"},{"value":0.3619,"timestamp":1792722000,"date":"2026-10-23T02:20:00.000Z"},{"value":0.3619,"timestamp":1792722300,"date":"2026-10-23T02:25:00.000Z"},{"value":0.3619,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.3619,"timestamp":1792722900,"date":"2026-10-23T02:35:00.000Z"},{"value":0.3619,"timestamp":1792723200,"date":"2026-10-23T02:40:00.000Z"},{"value":0.3619,"timestamp":1792723500,"date":"2026-10-23T02:45:00.000Z"},{"value":0.3619,"timestamp":1792723800,"date":"2026-10-23T02:50:00.000Z"},{"value":0.3619,"timestamp":1792724100,"date":"2026-10-23T02:55:00.000Z"},{"value":0.3619,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.3619,"timestamp":1792724700,"date":"2026-10-23T03:05:00.000Z"},{"value":0.3619,"timestamp":1792725000,"date":"2026-10-23T03:10:00.000Z"},{"value":0.3619,"timestamp":1792725300,"date":"2026-10-23T03:15:00.000Z"},{"value":0.3619,"timestamp":1792725600,"date":"2026-10-23T03:20:00.000Z"},{"value":0.3619,"timestamp":1792725900,"date":"2026-10-23T03:25:00.000Z"},{"value":0.3619,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.3619,"timestamp":1792726500,"date":"2026-10-23T03:35:00.000Z"},{"value":0.3619,"timestamp":1792726800,"date":"2026-10-23T03:40:00.000Z"},{"value":0.3619,"timestamp":1792727100,"date":"2026-10-23T03:45:00.000Z"},{"value":0.3619,"timestamp":1792727400,"date":"2026-10-23T03:50:00.000Z"},{"value":0.3619,"timestamp":1792727700,"date":"2026-10-23T03:55:00.000Z"},{"value":0.3619,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.3619,"timestamp":1792728300,"date":"2026-10-23T04:05:00.000Z"},{"value":0.3619,"timestamp":1792728600,"date":"2026-10-23T04:10:00.000Z"},{"value":0.3619,"timestamp":1792728900,"date":"2026-10-23T04:15:00.000Z"},{"value":0.3619,"timestamp":1792729200,"date":"2026-10-23T04:20:00.000Z"},{"value":0.3619,"timestamp":1792729500,"date":"2026-10-23T04:25:00.000Z"},{"value":0.3619,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.3619,"timestamp":1792730100,"date":"2026-10-23T04:35:00.000Z"},{"value":0.3619,"timestamp":1792730400,"date":"2026-10-23T04:40:00.000Z"},{"value":0.3619,"timestamp":1792730700,"date":"2026-10-23T04:45:00.000Z"},{"value":0.3619,"timestamp":1792731000,"date":"2026-10-23T04:50:00.000Z"},{"value":0.3619,"timestamp":1792731300,"date":"2026-10-23T04:55:00.000Z"},{"value":0.3619,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.3619,"timestamp":1792731900,"date":"2026-10-23T05:05:00.000Z"},{"value":0.3619,"timestamp":1792732200,"date":"2026-10-23T05:10:00.000Z"},{"value":0.3619,"timestamp":1792732500,"date":"2026-10-23T05:15:00.000Z"},{"value":0.3619,"timestamp":1792732800,"date":"2026-10-23T05:20:00.000Z"},{"value":0.3619,"timestamp":1792733100,"date":"2026-10-23T05:25:00.000Z"},{"value":0.3619,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.3619,"timestamp":1792733700,"date":"2026-10-23T05:35:00.000Z"},{"value":0.3619,"timestamp":1792734000,"date":"2026-10-23T05:40:00.000Z"},{"value":0.3619,"timestamp":1792734300,"date":"2026-10-23T05:45:00.000Z"},{"value":0.3619,"timestamp":1792734600,"date":"2026-10-23T05:50:00.000Z"},{"value":0.3619,"timestamp":1792734900,"date":"2026-10-23T05:55:00.000Z"},{"value":0.3619,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.3619,"timestamp":1792735500,"date":"2026-10-23T06:05:00.000Z"},{"value":0.3619,"timestamp":1792735800,"date":"2026-10-23T06:10:00.000Z"},{"value":0.3619,"timestamp":1792736100,"date":"2026-10-23T06:15:00.000Z"},{"value":0.3619,"timestamp":1792736400,"date":"2026-10-23T06:20:00.000Z"},{"value":0.3619,"timestamp":1792736700,"date":"2026-10-23T06:25:00.000Z"},{"value":0.3619,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.3619,"timestamp":1792737300,"date":"2026-10-23T06:35:00.000Z"},{"value":0.3619,"timestamp":1792737600,"date":"2026-10-23T06:40:00.000Z"},{"value":0.3619,"timestamp":1792737900,"date":"2026-10-23T06:45:00.000Z"},{"value":0.3619,"timestamp":1792738200,"date":"2026-10-23T06:50:00.000Z"},{"value":0.3619,"timestamp":1792738500,"date":"2026-10-23T06:55:00.000Z"},{"value":0.3619,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.3619,"timestamp":1792739100,"date":"2026-10-23T07:05:00.000Z"},{"value":0.3619,"timestamp":1792739400,"date":"2026-10-23T07:10:00.000Z"},{"value":0.3619,"timestamp":1792739700,"date":"2026-10-23T07:15:00.000Z"},{"value":0.3619,"timestamp":1792740000,"date":"2026-10-23T07:20:00.000Z"},{"value":0.3619,"timestamp":1792740300,"date":"2026-10-23T07:25:00.000Z"},{"value":0.3619,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.3619,"timestamp":1792740900,"date":"2026-10-23T07:35:00.000Z"},{"value":0.3619,"timestamp":1792741200,"date":"2026-10-23T07:40:00.000Z"},{"value":0.3619,"timestamp":1792741500,"date":"2026-10-23T07:45:00.000Z"},{"value":0.3619,"timestamp":1792741800,"date":"2026-10-23T07:50:00.000Z"},{"value":0.3619,"timestamp":1792742100,"date":"2026-10-23T07:55:00.000Z"},{"value":0.3619,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.3619,"timestamp":1792742700,"date":"2026-10-23T08:05:00.000Z"},{"value":0.3619,"timestamp":1792743000,"date":"2026-10-23T08:10:00.000Z"},{"value":0.3619,"timestamp":1792743300,"date":"2026-10-23T08:15:00.000Z"},{"value":0.3619,"timestamp":1792743600,"date":"2026-10-23T08:20:00.000Z"},{"value":0.3619,"timestamp":1792743900,"date":"2026-10-23T08:25:00.000Z"},{"value":0.3619,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.3619,"timestamp":1792744500,"date":"2026-10-23T08:35:00.000Z"},{"value":0.3619,"timestamp":1792744800,"date":"2026-10-23T08:40:00.000Z"},{"value":0.3619,"timestamp":1792745100,"date":"2026-10-23T08:45:00.000Z"},{"value":0.3619,"timestamp":1792745400,"date":"2026-10-23T08:50:00.000Z"},{"value":0.3619,"timestamp":1792745700,"date":"2026-10-23T08:55:00.000Z"},{"value":0.3619,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.3619,"timestamp":1792746300,"date":"2026-10-23T09:05:00.000Z"},{"value":0.3619,"timestamp":1792746600,"date":"2026-10-23T09:10:00.000Z"},{"value":0.3619,"timestamp":1792746900,"date":"2026-10-23T09:15:00.000Z"},{"value":0.3619,"timestamp":1792747200,"date":"2026-10-23T09:20:00.000Z"},{"value":0.3619,"timestamp":1792747500,"date":"2026-10-23T09:25:00.000Z"},{"value":0.3619,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.3619,"timestamp":1792748100,"date":"2026-10-23T09:35:00.000Z"},{"value":0.3619,"timestamp":1792748400,"date":"2026-10-23T09:40:00.000Z"},{"value":0.3619,"timestamp":1792748700,"date":"2026-10-23T09:45:00.000Z"},{"value":0.3619,"timestamp":1792749000,"date":"2026-10-23T09:50:00.000Z"},{"value":0.3619,"timestamp":1792749300,"date":"2026-10-23T09:55:00.000Z"},{"value":0.3619,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.3619,"timestamp":1792749900,"date":"2026-10-23T10:05:00.000Z"},{"value":0.3619,"timestamp":1792750200,"date":"2026-10-23T10:10:00.000Z"},{"value":0.3619,"timestamp":1792750500,"date":"2026-10-23T10:15:00.000Z"},{"value":0.3619,"timestamp":1792750800,"date":"2026-10-23T10:20:00.000Z"},{"value":0.3619,"timestamp":1792751100,"date":"2026-10-23T10:25:00.000Z"},{"value":0.3622,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.3616,"timestamp":1792751700,"date":"2026-10-23T10:35:00.000Z"},{"value":0.3622,"timestamp":1792752000,"date":"2026-10-23T10:40:00.000Z"},{"value":0.3618,"timestamp":1792752300,"date":"2026-10-23T10:45:00.000Z"},{"value":0.3622,"timestamp":1792752600,"date":"2026-10-23T10:50:00.000Z"},{"value":0.3613,"timestamp":1792752900,"date":"2026-10-23T10:55:00.000Z"},{"value":0.3622,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.3619,"timestamp":1792753500,"date":"2026-10-23T11:05:00.000Z"},{"value":0.3619,"timestamp":1792753800,"date":"2026-10-23T11:10:00.000Z"},{"value":0.3587,"timestamp":1792754100,"date":"2026-10-23T11:15:00.000Z"},{"value":0.3598,"timestamp":1792754400,"date":"2026-10-23T11:20:00.000Z"},{"value":0.3615,"timestamp":1792754700,"date":"2026-10-23T11:25:00.000Z"},{"value":0.359,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.3593,"timestamp":1792755300,"date":"2026-10-23T11:35:00.000Z"},{"value":0.3612,"timestamp":1792755600,"date":"2026-10-23T11:40:00.000Z"},{"value":0.3603,"timestamp":1792755900,"date":"2026-10-23T11:45:00.000Z"},{"value":0.3612,"timestamp":1792756200,"date":"2026-10-23T11:50:00.000Z"},{"value":0.362,"timestamp":1792756500,"date":"2026-10-23T11:55:00.000Z"},{"value":0.3495,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.3495,"timestamp":1792757100,"date":"2026-10-23T12:05:00.000Z"},{"value":0.3448,"timestamp":1792757400,"date":"2026-10-23T12:10:00.000Z"},{"value":0.3487,"timestamp":1792757700,"date":"2026-10-23T12:15:00.000Z"},{"value":0.3462,"timestamp":1792758000,"date":"2026-10-23T12:20:00.000Z"},{"value":0.3498,"timestamp":1792758300,"date":"2026-10-23T12:25:00.000Z"},{"value":0.3496,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.3499,"timestamp":1792758900,"date":"2026-10-23T12:35:00.000Z"},{"value":0.3496,"timestamp":1792759200,"date":"2026-10-23T12:40:00.000Z"},{"value":0.3496,"timestamp":1792759500,"date":"2026-10-23T12:45:00.000Z"},{"value":0.3497,"timestamp":1792759800,"date":"2026-10-23T12:50:00.000Z"},{"value":0.3517,"timestamp":1792760100,"date":"2026-10-23T12:55:00.000Z"},{"value":0.3466,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.3497,"timestamp":1792760700,"date":"2026-10-23T13:05:00.000Z"},{"value":0.3513,"timestamp":1792761000,"date":"2026-10-23T13:10:00.000Z"},{"value":0.3497,"timestamp":1792761300,"date":"2026-10-23T13:15:00.000Z"},{"value":0.3377,"timestamp":1792761600,"date":"2026-10-23T13:20:00.000Z"},{"value":0.3377,"timestamp":1792761900,"date":"2026-10-23T13:25:00.000Z"},{"value":0.3377,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.3377,"timestamp":1792762500,"date":"2026-10-23T13:35:00.000Z"},{"value":0.3377,"timestamp":1792762800,"date":"2026-10-23T13:40:00.000Z"},{"value":0.3262,"timestamp":1792763100,"date":"2026-10-23T13:45:00.000Z"},{"value":0.3262,"timestamp":1792763400,"date":"2026-10-23T13:50:00.000Z"},{"value":0.3262,"timestamp":1792763700,"date":"2026-10-23T13:55:00.000Z"},{"value":0.3262,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.3262,"timestamp":1792764300,"date":"2026-10-23T14:05:00.000Z"},{"value":0.3262,"timestamp":1792764600,"date":"2026-10-23T14:10:00.000Z"},{"value":0.3262,"timestamp":1792764900,"date":"2026-10-23T14:15:00.000Z"},{"value":0.3262,"timestamp":1792765200,"date":"2026-10-23T14:20:00.000Z"},{"value":0.3262,"timestamp":1792765500,"date":"2026-10-23T14:25:00.000Z"},{"value":0.3262,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.3262,"timestamp":1792766100,"date":"2026-10-23T14:35:00.000Z"},{"value":0.3262,"timestamp":1792766400,"date":"2026-10-23T14:40:00.000Z"},{"value":0.3262,"timestamp":1792766700,"date":"2026-10-23T14:45:00.000Z"},{"value":0.3262,"timestamp":1792767000,"date":"2026-10-23T14:50:00.000Z"},{"value":0.3262,"timestamp":1792767300,"date":"2026-10-23T14:55:00.000Z"},{"value":0.3262,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.3262,"timestamp":1792767900,"date":"2026-10-23T15:05:00.000Z"},{"value":0.3262,"timestamp":1792768200,"date":"2026-10-23T15:10:00.000Z"},{"value":0.3262,"timestamp":1792768500,"date":"2026-10-23T15:15:00.000Z"},{"value":0.3262,"timestamp":1792768800,"date":"2026-10-23T15:20:00.000Z"},{"value":0.3262,"timestamp":1792769100,"date":"2026-10-23T15:25:00.000Z"},{"value":0.3262,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"},{"value":0.3262,"timestamp":1792769700,"date":"2026-10-23T15:35:00.000Z"},{"value":0.3262,"timestamp":1792770000,"date":"2026-10-23T15:40:00.000Z"},{"value":0.3267,"timestamp":1792770300,"date":"2026-10-23T15:45:00.000Z"},{"value":0.3265,"timestamp":1792770600,"date":"2026-10-23T15:50:00.000Z"},{"value":0.3265,"timestamp":1792770900,"date":"2026-10-23T15:55:00.000Z"}],"change_percent":-0.11970881639255862},{"timeframe":"7d","prices":[{"value":0.3645,"timestamp":1792171800,"date":"2026-10-16T17:30:00.000Z"},{"value":0.3645,"timestamp":1792173600,"date":"2026-10-16T18:00:00.000Z"},{"value":0.3645,"timestamp":1792175400,"date":"2026-10-16T18:30:00.000Z"},{"value":0.3645,"timestamp":1792177200,"date":"2026-10-16T19:00:00.000Z"},{"value":0.3645,"timestamp":1792179000,"date":"2026-10-16T19:30:00.000Z"},{"value":0.3645,"timestamp":1792180800,"date":"2026-10-16T20:00:00.000Z"},{"value":0.3645,"timestamp":1792182600,"date":"2026-10-16T20:30:00.000Z"},{"value":0.3645,"timestamp":1792184400,"date":"2026-10-16T21:00:00.000Z"},{"value":0.3645,"timestamp":1792186200,"date":"2026-10-16T21:30:00.000Z"},{"value":0.3645,"timestamp":1792188000,"date":"2026-10-16T22:00:00.000Z"},{"value":0.3645,"timestamp":1792189800,"date":"2026-10-16T22:30:00.000Z"},{"value":0.3645,"timestamp":1792191600,"date":"2026-10-16T23:00:00.000Z"},{"value":0.3645,"timestamp":1792193400,"date":"2026-10-16T23:30:00.000Z"},{"value":0.3645,"timestamp":1792195200,"date":"2026-10-17T00:00:00.000Z"},{"value":0.3645,"timestamp":1792197000,"date":"2026-10-17T00:30:00.000Z"},{"value":0.3645,"timestamp":1792198800,"date":"2026-10-17T01:00:00.000Z"},{"value":0.3645,"timestamp":1792200600,"date":"2026-10-17T01:30:00.000Z"},{"value":0.3645,"timestamp":1792202400,"date":"2026-10-17T02:00:00.000Z"},{"value":0.3645,"timestamp":1792204200,"date":"2026-10-17T02:30:00.000Z"},{"value":0.3645,"timestamp":1792206000,"date":"2026-10-17T03:00:00.000Z"},{"value":0.3645,"timestamp":1792207800,"date":"2026-10-17T03:30:00.000Z"},{"value":0.3645,"timestamp":1792209600,"date":"2026-10-17T04:00:00.000Z"},{"value":0.3645,"timestamp":1792211400,"date":"2026-10-17T04:30:00.000Z"},{"value":0.3645,"timestamp":1792213200,"date":"2026-10-17T05:00:00.000Z"},{"value":0.3645,"timestamp":1792215000,"date":"2026-10-17T05:30:00.000Z"},{"value":0.3645,"timestamp":1792216800,"date":"2026-10-17T06:00:00.000Z"},{"value":0.3645,"timestamp":1792218600,"date":"2026-10-17T06:30:00.000Z"},{"value":0.3645,"timestamp":1792220400,"date":"2026-10-17T07:00:00.000Z"},{"value":0.3645,"timestamp":1792222200,"date":"2026-10-17T07:30:00.000Z"},{"value":0.3645,"timestamp":1792224000,"date":"2026-10-17T08:00:00.000Z"},{"value":0.3645,"timestamp":1792225800,"date":"2026-10-17T08:30:00.000Z"},{"value":0.3645,"timestamp":1792227600,"date":"2026-10-17T09:00:00.000Z"},{"value":0.3645,"timestamp":1792229400,"date":"2026-10-17T09:30:00.000Z"},{"value":0.3645,"timestamp":1792231200,"date":"2026-10-17T10:00:00.000Z"},{"value":0.3645,"timestamp":1792233000,"date":"2026-10-17T10:30:00.000Z"},{"value":0.3645,"timestamp":1792234800,"date":"2026-10-17T11:00:00.000Z"},{"value":0.3645,"timestamp":1792236600,"date":"2026-10-17T11:30:00.000Z"},{"value":0.3645,"timestamp":1792238400,"date":"2026-10-17T12:00:00.000Z"},{"value":0.3645,"timestamp":1792240200,"date":"2026-10-17T12:30:00.000Z"},{"value":0.3645,"timestamp":1792242000,"date":"2026-10-17T13:00:00.000Z"},{"value":0.3645,"timestamp":1792243800,"date":"2026-10-17T13:30:00.000Z"},{"value":0.3645,"timestamp":1792245600,"date":"2026-10-17T14:00:00.000Z"},{"value":0.3645,"timestamp":1792247400,"date":"2026-10-17T14:30:00.000Z"},{"value":0.3645,"timestamp":1792249200,"date":"2026-10-17T15:00:00.000Z"},{"value":0.3645,"timestamp":1792251000,"date":"2026-10-17T15:30:00.000Z"},{"value":0.3645,"timestamp":1792252800,"date":"2026-10-17T16:00:00.000Z"},{"value":0.3645,"timestamp":1792254600,"date":"2026-10-17T16:30:00.000Z"},{"value":0.3645,"timestamp":1792256400,"date":"2026-10-17T17:00:00.000Z"},{"value":0.3645,"timestamp":1792258200,"date":"2026-10-17T17:30:00.000Z"},{"value":0.3645,"timestamp":1792260000,"date":"2026-10-17T18:00:00.000Z"},{"value":0.3645,"timestamp":1792261800,"date":"2026-10-17T18:30:00.000Z"},{"value":0.3645,"timestamp":1792263600,"date":"2026-10-17T19:00:00.000Z"},{"value":0.3645,"timestamp":1792265400,"date":"2026-10-17T19:30:00.000Z"},{"value":0.3645,"timestamp":1792267200,"date":"2026-10-17T20:00:00.000Z"},{"value":0.3645,"timestamp":1792269000,"date":"2026-10-17T20:30:00.000Z"},{"value":0.3645,"timestamp":1792270800,"date":"2026-10-17T21:00:00.000Z"},{"value":0.3645,"timestamp":1792272600,"date":"2026-10-17T21:30:00.000Z"},{"value":0.3645,"timestamp":1792274400,"date":"2026-10-17T22:00:00.000Z"},{"value":0.3645,"timestamp":1792276200,"date":"2026-10-17T22:30:00.000Z"},{"value":0.3645,"timestamp":1792278000,"date":"2026-10-17T23:00:00.000Z"},{"value":0.3645,"timestamp":1792279800,"date":"2026-10-17T23:30:00.000Z"},{"value":0.3645,"timestamp":1792281600,"date":"2026-10-18T00:00:00.000Z"},{"value":0.3645,"timestamp":1792283400,"date":"2026-10-18T00:30:00.000Z"},{"value":0.3645,"timestamp":1792285200,"date":"2026-10-18T01:00:00.000Z"},{"value":0.3645,"timestamp":1792287000,"date":"2026-10-18T01:30:00.000Z"},{"value":0.3645,"timestamp":1792288800,"date":"2026-10-18T02:00:00.000Z"},{"value":0.3645,"timestamp":1792290600,"date":"2026-10-18T02:30:00.000Z"},{"value":0.3645,"timestamp":1792292400,"date":"2026-10-18T03:00:00.000Z"},{"value":0.3645,"timestamp":1792294200,"date":"2026-10-18T03:30:00.000Z"},{"value":0.3645,"timestamp":1792296000,"date":"2026-10-18T04:00:00.000Z"},{"value":0.3645,"timestamp":1792297800,"date":"2026-10-18T04:30:00.000Z"},{"value":0.3645,"timestamp":1792299600,"date":"2026-10-18T05:00:00.000Z"},{"value":0.3645,"timestamp":1792301400,"date":"2026-10-18T05:30:00.000Z"},{"value":0.3645,"timestamp":1792303200,"date":"2026-10-18T06:00:00.000Z"},{"value":0.3645,"timestamp":1792305000,"date":"2026-10-18T06:30:00.000Z"},{"value":0.3645,"timestamp":1792306800,"date":"2026-10-18T07:00:00.000Z"},{"value":0.3645,"timestamp":1792308600,"date":"2026-10-18T07:30:00.000Z"},{"value":0.3645,"timestamp":1792310400,"date":"2026-10-18T08:00:00.000Z"},{"value":0.3645,"timestamp":1792312200,"date":"2026-10-18T08:30:00.000Z"},{"value":0.3645,"timestamp":1792314000,"date":"2026-10-18T09:00:00.000Z"},{"value":0.3645,"timestamp":1792315800,"date":"2026-10-18T09:30:00.000Z"},{"value":0.3645,"timestamp":1792317600,"date":"2026-10-18T10:00:00.000Z"},{"value":0.3645,"timestamp":1792319400,"date":"2026-10-18T10:30:00.000Z"},{"value":0.3645,"timestamp":1792321200,"date":"2026-10-18T11:00:00.000Z"},{"value":0.3645,"timestamp":1792323000,"date":"2026-10-18T11:30:00.000Z"},{"value":0.3645,"timestamp":1792324800,"date":"2026-10-18T12:00:00.000Z"},{"value":0.3645,"timestamp":1792326600,"date":"2026-10-18T12:30:00.000Z"},{"value":0.3645,"timestamp":1792328400,"date":"2026-10-18T13:00:00.000Z"},{"value":0.3645,"timestamp":1792330200,"date":"2026-10-18T13:30:00.000Z"},{"value":0.3645,"timestamp":1792332000,"date":"2026-10-18T14:00:00.000Z"},{"value":0.3645,"timestamp":1792333800,"date":"2026-10-18T14:30:00.000Z"},{"value":0.3645,"timestamp":1792335600,"date":"2026-10-18T15:00:00.000Z"},{"value":0.3645,"timestamp":1792337400,"date":"2026-10-18T15:30:00.000Z"},{"value":0.3645,"timestamp":1792339200,"date":"2026-10-18T16:00:00.000Z"},{"value":0.3645,"timestamp":1792341000,"date":"2026-10-18T16:30:00.000Z"},{"value":0.3645,"timestamp":1792342800,"date":"2026-10-18T17:00:00.000Z"},{"value":0.3645,"timestamp":1792344600,"date":"2026-10-18T17:30:00.000Z"},{"value":0.3645,"timestamp":1792346400,"date":"2026-10-18T18:00:00.000Z"},{"value":0.3645,"timestamp":1792348200,"date":"2026-10-18T18:30:00.000Z"},{"value":0.3645,"timestamp":1792350000,"date":"2026-10-18T19:00:00.000Z"},{"value":0.3645,"timestamp":1792351800,"date":"2026-10-18T19:30:00.000Z"},{"value":0.3645,"timestamp":1792353600,"date":"2026-10-18T20:00:00.000Z"},{"value":0.3645,"timestamp":1792355400,"date":"2026-10-18T20:30:00.000Z"},{"value":0.3645,"timestamp":1792357200,"date":"2026-10-18T21:00:00.000Z"},{"value":0.3645,"timestamp":1792359000,"date":"2026-10-18T21:30:00.000Z"},{"value":0.3645,"timestamp":1792360800,"date":"2026-10-18T22:00:00.000Z"},{"value":0.3645,"timestamp":1792362600,"date":"2026-10-18T22:30:00.000Z"},{"value":0.3645,"timestamp":1792364400,"date":"2026-10-18T23:00:00.000Z"},{"value":0.3645,"timestamp":1792366200,"date":"2026-10-18T23:30:00.000Z"},{"value":0.3645,"timestamp":1792368000,"date":"2026-10-19T00:00:00.000Z"},{"value":0.3663,"timestamp":1792369800,"date":"2026-10-19T00:30:00.000Z"},{"value":0.3645,"timestamp":1792371600,"date":"2026-10-19T01:00:00.000Z"},{"value":0.3663,"timestamp":1792373400,"date":"2026-10-19T01:30:00.000Z"},{"value":0.3645,"timestamp":1792375200,"date":"2026-10-19T02:00:00.000Z"},{"value":0.3664,"timestamp":1792377000,"date":"2026-10-19T02:30:00.000Z"},{"value":0.3664,"timestamp":1792378800,"date":"2026-10-19T03:00:00.000Z"},{"value":0.3645,"timestamp":1792380600,"date":"2026-10-19T03:30:00.000Z"},{"value":0.3666,"timestamp":1792382400,"date":"2026-10-19T04:00:00.000Z"},{"value":0.3667,"timestamp":1792384200,"date":"2026-10-19T04:30:00.000Z"},{"value":0.3669,"timestamp":1792386000,"date":"2026-10-19T05:00:00.000Z"},{"value":0.3672,"timestamp":1792387800,"date":"2026-10-19T05:30:00.000Z"},{"value":0.3649,"timestamp":1792389600,"date":"2026-10-19T06:00:00.000Z"},{"value":0.3649,"timestamp":1792391400,"date":"2026-10-19T06:30:00.000Z"},{"value":0.3649,"timestamp":1792393200,"date":"2026-10-19T07:00:00.000Z"},{"value":0.3649,"timestamp":1792395000,"date":"2026-10-19T07:30:00.000Z"},{"value":0.3649,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.3649,"timestamp":1792398600,"date":"2026-10-19T08:30:00.000Z"},{"value":0.3649,"timestamp":1792400400,"date":"2026-10-19T09:00:00.000Z"},{"value":0.3672,"timestamp":1792402200,"date":"2026-10-19T09:30:00.000Z"},{"value":0.3673,"timestamp":1792404000,"date":"2026-10-19T10:00:00.000Z"},{"value":0.3651,"timestamp":1792405800,"date":"2026-10-19T10:30:00.000Z"},{"value":0.3651,"timestamp":1792407600,"date":"2026-10-19T11:00:00.000Z"},{"value":0.3651,"timestamp":1792409400,"date":"2026-10-19T11:30:00.000Z"},{"value":0.3651,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.3674,"timestamp":1792413000,"date":"2026-10-19T12:30:00.000Z"},{"value":0.3674,"timestamp":1792414800,"date":"2026-10-19T13:00:00.000Z"},{"value":0.3652,"timestamp":1792416600,"date":"2026-10-19T13:30:00.000Z"},{"value":0.3652,"timestamp":1792418400,"date":"2026-10-19T14:00:00.000Z"},{"value":0.3652,"timestamp":1792420200,"date":"2026-10-19T14:30:00.000Z"},{"value":0.3652,"timestamp":1792422000,"date":"2026-10-19T15:00:00.000Z"},{"value":0.3652,"timestamp":1792423800,"date":"2026-10-19T15:30:00.000Z"},{"value":0.3652,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.3652,"timestamp":1792427400,"date":"2026-10-19T16:30:00.000Z"},{"value":0.3652,"timestamp":1792429200,"date":"2026-10-19T17:00:00.000Z"},{"value":0.3652,"timestamp":1792431000,"date":"2026-10-19T17:30:00.000Z"},{"value":0.3652,"timestamp":1792432800,"date":"2026-10-19T18:00:00.000Z"},{"value":0.3652,"timestamp":1792434600,"date":"2026-10-19T18:30:00.000Z"},{"value":0.3652,"timestamp":1792436400,"date":"2026-10-19T19:00:00.000Z"},{"value":0.3652,"timestamp":1792438200,"date":"2026-10-19T19:30:00.000Z"},{"value":0.3652,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.3652,"timestamp":1792441800,"date":"2026-10-19T20:30:00.000Z"},{"value":0.3652,"timestamp":1792443600,"date":"2026-10-19T21:00:00.000Z"},{"value":0.3652,"timestamp":1792445400,"date":"2026-10-19T21:30:00.000Z"},{"value":0.3652,"timestamp":1792447200,"date":"2026-10-19T22:00:00.000Z"},{"value":0.3652,"timestamp":1792449000,"date":"2026-10-19T22:30:00.000Z"},{"value":0.3652,"timestamp":1792450800,"date":"2026-10-19T23:00:00.000Z"},{"value":0.3674,"timestamp":1792452600,"date":"2026-10-19T23:30:00.000Z"},{"value":0.3673,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.3673,"timestamp":1792456200,"date":"2026-10-20T00:30:00.000Z"},{"value":0.3673,"timestamp":1792458000,"date":"2026-10-20T01:00:00.000Z"},{"value":0.3673,"timestamp":1792459800,"date":"2026-10-20T01:30:00.000Z"},{"value":0.3673,"timestamp":1792461600,"date":"2026-10-20T02:00:00.000Z"},{"value":0.3673,"timestamp":1792463400,"date":"2026-10-20T02:30:00.000Z"},{"value":0.3673,"timestamp":1792465200,"date":"2026-10-20T03:00:00.000Z"},{"value":0.3673,"timestamp":1792467000,"date":"2026-10-20T03:30:00.000Z"},{"value":0.3673,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.3673,"timestamp":1792470600,"date":"2026-10-20T04:30:00.000Z"},{"value":0.3673,"timestamp":1792472400,"date":"2026-10-20T05:00:00.000Z"},{"value":0.3673,"timestamp":1792474200,"date":"2026-10-20T05:30:00.000Z"},{"value":0.3673,"timestamp":1792476000,"date":"2026-10-20T06:00:00.000Z"},{"value":0.3673,"timestamp":1792477800,"date":"2026-10-20T06:30:00.000Z"},{"value":0.3673,"timestamp":1792479600,"date":"2026-10-20T07:00:00.000Z"},{"value":0.3673,"timestamp":1792481400,"date":"2026-10-20T07:30:00.000Z"},{"value":0.3673,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.3673,"timestamp":1792485000,"date":"2026-10-20T08:30:00.000Z"},{"value":0.3673,"timestamp":1792486800,"date":"2026-10-20T09:00:00.000Z"},{"value":0.3673,"timestamp":1792488600,"date":"2026-10-20T09:30:00.000Z"},{"value":0.3673,"timestamp":1792490400,"date":"2026-10-20T10:00:00.000Z"},{"value":0.3546,"timestamp":1792492200,"date":"2026-10-20T10:30:00.000Z"},{"value":0.3546,"timestamp":1792494000,"date":"2026-10-20T11:00:00.000Z"},{"value":0.3546,"timestamp":1792495800,"date":"2026-10-20T11:30:00.000Z"},{"value":0.3546,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.3525,"timestamp":1792499400,"date":"2026-10-20T12:30:00.000Z"},{"value":0.3525,"timestamp":1792501200,"date":"2026-10-20T13:00:00.000Z"},{"value":0.3525,"timestamp":1792503000,"date":"2026-10-20T13:30:00.000Z"},{"value":0.3546,"timestamp":1792504800,"date":"2026-10-20T14:00:00.000Z"},{"value":0.3734,"timestamp":1792506600,"date":"2026-10-20T14:30:00.000Z"},{"value":0.3734,"timestamp":1792508400,"date":"2026-10-20T15:00:00.000Z"},{"value":0.3734,"timestamp":1792510200,"date":"2026-10-20T15:30:00.000Z"},{"value":0.3734,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.3734,"timestamp":1792513800,"date":"2026-10-20T16:30:00.000Z"},{"value":0.3734,"timestamp":1792515600,"date":"2026-10-20T17:00:00.000Z"},{"value":0.3734,"timestamp":1792517400,"date":"2026-10-20T17:30:00.000Z"},{"value":0.3734,"timestamp":1792519200,"date":"2026-10-20T18:00:00.000Z"},{"value":0.3734,"timestamp":1792521000,"date":"2026-10-20T18:30:00.000Z"},{"value":0.3734,"timestamp":1792522800,"date":"2026-10-20T19:00:00.000Z"},{"value":0.3734,"timestamp":1792524600,"date":"2026-10-20T19:30:00.000Z"},{"value":0.3734,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.3734,"timestamp":1792528200,"date":"2026-10-20T20:30:00.000Z"},{"value":0.3734,"timestamp":1792530000,"date":"2026-10-20T21:00:00.000Z"},{"value":0.3734,"timestamp":1792531800,"date":"2026-10-20T21:30:00.000Z"},{"value":0.3734,"timestamp":1792533600,"date":"2026-10-20T22:00:00.000Z"},{"value":0.3734,"timestamp":1792535400,"date":"2026-10-20T22:30:00.000Z"},{"value":0.3734,"timestamp":1792537200,"date":"2026-10-20T23:00:00.000Z"},{"value":0.3734,"timestamp":1792539000,"date":"2026-10-20T23:30:00.000Z"},{"value":0.3734,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.3734,"timestamp":1792542600,"date":"2026-10-21T00:30:00.000Z"},{"value":0.3734,"timestamp":1792544400,"date":"2026-10-21T01:00:00.000Z"},{"value":0.3734,"timestamp":1792546200,"date":"2026-10-21T01:30:00.000Z"},{"value":0.3734,"timestamp":1792548000,"date":"2026-10-21T02:00:00.000Z"},{"value":0.3734,"timestamp":1792549800,"date":"2026-10-21T02:30:00.000Z"},{"value":0.3734,"timestamp":1792551600,"date":"2026-10-21T03:00:00.000Z"},{"value":0.3734,"timestamp":1792553400,"date":"2026-10-21T03:30:00.000Z"},{"value":0.3734,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.3734,"timestamp":1792557000,"date":"2026-10-21T04:30:00.000Z"},{"value":0.3734,"timestamp":1792558800,"date":"2026-10-21T05:00:00.000Z"},{"value":0.3734,"timestamp":1792560600,"date":"2026-10-21T05:30:00.000Z"},{"value":0.3734,"timestamp":1792562400,"date":"2026-10-21T06:00:00.000Z"},{"value":0.3734,"timestamp":1792564200,"date":"2026-10-21T06:30:00.000Z"},{"value":0.3734,"timestamp":1792566000,"date":"2026-10-21T07:00:00.000Z"},{"value":0.3734,"timestamp":1792567800,"date":"2026-10-21T07:30:00.000Z"},{"value":0.3734,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.3734,"timestamp":1792571400,"date":"2026-10-21T08:30:00.000Z"},{"value":0.3888,"timestamp":1792573200,"date":"2026-10-21T09:00:00.000Z"},{"value":0.3888,"timestamp":1792575000,"date":"2026-10-21T09:30:00.000Z"},{"value":0.3888,"timestamp":1792576800,"date":"2026-10-21T10:00:00.000Z"},{"value":0.3888,"timestamp":1792578600,"date":"2026-10-21T10:30:00.000Z"},{"value":0.3888,"timestamp":1792580400,"date":"2026-10-21T11:00:00.000Z"},{"value":0.3888,"timestamp":1792582200,"date":"2026-10-21T11:30:00.000Z"},{"value":0.3888,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.3888,"timestamp":1792585800,"date":"2026-10-21T12:30:00.000Z"},{"value":0.3888,"timestamp":1792587600,"date":"2026-10-21T13:00:00.000Z"},{"value":0.3888,"timestamp":1792589400,"date":"2026-10-21T13:30:00.000Z"},{"value":0.3888,"timestamp":1792591200,"date":"2026-10-21T14:00:00.000Z"},{"value":0.3888,"timestamp":1792593000,"date":"2026-10-21T14:30:00.000Z"},{"value":0.3751,"timestamp":1792594800,"date":"2026-10-21T15:00:00.000Z"},{"value":0.3751,"timestamp":1792596600,"date":"2026-10-21T15:30:00.000Z"},{"value":0.3751,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.3751,"timestamp":1792600200,"date":"2026-10-21T16:30:00.000Z"},{"value":0.3751,"timestamp":1792602000,"date":"2026-10-21T17:00:00.000Z"},{"value":0.3751,"timestamp":1792603800,"date":"2026-10-21T17:30:00.000Z"},{"value":0.3751,"timestamp":1792605600,"date":"2026-10-21T18:00:00.000Z"},{"value":0.3751,"timestamp":1792607400,"date":"2026-10-21T18:30:00.000Z"},{"value":0.3751,"timestamp":1792609200,"date":"2026-10-21T19:00:00.000Z"},{"value":0.3751,"timestamp":1792611000,"date":"2026-10-21T19:30:00.000Z"},{"value":0.3751,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.3751,"timestamp":1792614600,"date":"2026-10-21T20:30:00.000Z"},{"value":0.3751,"timestamp":1792616400,"date":"2026-10-21T21:00:00.000Z"},{"value":0.3751,"timestamp":1792618200,"date":"2026-10-21T21:30:00.000Z"},{"value":0.3751,"timestamp":1792620000,"date":"2026-10-21T22:00:00.000Z"},{"value":0.3751,"timestamp":1792621800,"date":"2026-10-21T22:30:00.000Z"},{"value":0.3751,"timestamp":1792623600,"date":"2026-10-21T23:00:00.000Z"},{"value":0.3751,"timestamp":1792625400,"date":"2026-10-21T23:30:00.000Z"},{"value":0.3751,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.3751,"timestamp":1792629000,"date":"2026-10-22T00:30:00.000Z"},{"value":0.3751,"timestamp":1792630800,"date":"2026-10-22T01:00:00.000Z"},{"value":0.3751,"timestamp":1792632600,"date":"2026-10-22T01:30:00.000Z"},{"value":0.3751,"timestamp":1792634400,"date":"2026-10-22T02:00:00.000Z"},{"value":0.3751,"timestamp":1792636200,"date":"2026-10-22T02:30:00.000Z"},{"value":0.3751,"timestamp":1792638000,"date":"2026-10-22T03:00:00.000Z"},{"value":0.3751,"timestamp":1792639800,"date":"2026-10-22T03:30:00.000Z"},{"value":0.3751,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.3751,"timestamp":1792643400,"date":"2026-10-22T04:30:00.000Z"},{"value":0.3751,"timestamp":1792645200,"date":"2026-10-22T05:00:00.000Z"},{"value":0.3751,"timestamp":1792647000,"date":"2026-10-22T05:30:00.000Z"},{"value":0.3751,"timestamp":1792648800,"date":"2026-10-22T06:00:00.000Z"},{"value":0.3751,"timestamp":1792650600,"date":"2026-10-22T06:30:00.000Z"},{"value":0.3751,"timestamp":1792652400,"date":"2026-10-22T07:00:00.000Z"},{"value":0.3708,"timestamp":1792654200,"date":"2026-10-22T07:30:00.000Z"},{"value":0.3708,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.3708,"timestamp":1792657800,"date":"2026-10-22T08:30:00.000Z"},{"value":0.3708,"timestamp":1792659600,"date":"2026-10-22T09:00:00.000Z"},{"value":0.3708,"timestamp":1792661400,"date":"2026-10-22T09:30:00.000Z"},{"value":0.3708,"timestamp":1792663200,"date":"2026-10-22T10:00:00.000Z"},{"value":0.3708,"timestamp":1792665000,"date":"2026-10-22T10:30:00.000Z"},{"value":0.3708,"timestamp":1792666800,"date":"2026-10-22T11:00:00.000Z"},{"value":0.3708,"timestamp":1792668600,"date":"2026-10-22T11:30:00.000Z"},{"value":0.3708,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.3708,"timestamp":1792672200,"date":"2026-10-22T12:30:00.000Z"},{"value":0.3708,"timestamp":1792674000,"date":"2026-10-22T13:00:00.000Z"},{"value":0.3708,"timestamp":1792675800,"date":"2026-10-22T13:30:00.000Z"},{"value":0.3708,"timestamp":1792677600,"date":"2026-10-22T14:00:00.000Z"},{"value":0.3708,"timestamp":1792679400,"date":"2026-10-22T14:30:00.000Z"},{"value":0.3708,"timestamp":1792681200,"date":"2026-10-22T15:00:00.000Z"},{"value":0.3708,"timestamp":1792683000,"date":"2026-10-22T15:30:00.000Z"},{"value":0.3708,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.3622,"timestamp":1792686600,"date":"2026-10-22T16:30:00.000Z"},{"value":0.3622,"timestamp":1792688400,"date":"2026-10-22T17:00:00.000Z"},{"value":0.3622,"timestamp":1792690200,"date":"2026-10-22T17:30:00.000Z"},{"value":0.3622,"timestamp":1792692000,"date":"2026-10-22T18:00:00.000Z"},{"value":0.3622,"timestamp":1792693800,"date":"2026-10-22T18:30:00.000Z"},{"value":0.3622,"timestamp":1792695600,"date":"2026-10-22T19:00:00.000Z"},{"value":0.3622,"timestamp":1792697400,"date":"2026-10-22T19:30:00.000Z"},{"value":0.3622,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.3622,"timestamp":1792701000,"date":"2026-10-22T20:30:00.000Z"},{"value":0.3622,"timestamp":1792702800,"date":"2026-10-22T21:00:00.000Z"},{"value":0.3622,"timestamp":1792704600,"date":"2026-10-22T21:30:00.000Z"},{"value":0.3622,"timestamp":1792706400,"date":"2026-10-22T22:00:00.000Z"},{"value":0.3622,"timestamp":1792708200,"date":"2026-10-22T22:30:00.000Z"},{"value":0.3622,"timestamp":1792710000,"date":"2026-10-22T23:00:00.000Z"},{"value":0.3622,"timestamp":1792711800,"date":"2026-10-22T23:30:00.000Z"},{"value":0.3622,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.3622,"timestamp":1792715400,"date":"2026-10-23T00:30:00.000Z"},{"value":0.3622,"timestamp":1792717200,"date":"2026-10-23T01:00:00.000Z"},{"value":0.3622,"timestamp":1792719000,"date":"2026-10-23T01:30:00.000Z"},{"value":0.3619,"timestamp":1792720800,"date":"2026-10-23T02:00:00.000Z"},{"value":0.3619,"timestamp":1792722600,"date":"2026-10-23T02:30:00.000Z"},{"value":0.3619,"timestamp":1792724400,"date":"2026-10-23T03:00:00.000Z"},{"value":0.3619,"timestamp":1792726200,"date":"2026-10-23T03:30:00.000Z"},{"value":0.3619,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.3619,"timestamp":1792729800,"date":"2026-10-23T04:30:00.000Z"},{"value":0.3619,"timestamp":1792731600,"date":"2026-10-23T05:00:00.000Z"},{"value":0.3619,"timestamp":1792733400,"date":"2026-10-23T05:30:00.000Z"},{"value":0.3619,"timestamp":1792735200,"date":"2026-10-23T06:00:00.000Z"},{"value":0.3619,"timestamp":1792737000,"date":"2026-10-23T06:30:00.000Z"},{"value":0.3619,"timestamp":1792738800,"date":"2026-10-23T07:00:00.000Z"},{"value":0.3619,"timestamp":1792740600,"date":"2026-10-23T07:30:00.000Z"},{"value":0.3619,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.3619,"timestamp":1792744200,"date":"2026-10-23T08:30:00.000Z"},{"value":0.3619,"timestamp":1792746000,"date":"2026-10-23T09:00:00.000Z"},{"value":0.3619,"timestamp":1792747800,"date":"2026-10-23T09:30:00.000Z"},{"value":0.3619,"timestamp":1792749600,"date":"2026-10-23T10:00:00.000Z"},{"value":0.3622,"timestamp":1792751400,"date":"2026-10-23T10:30:00.000Z"},{"value":0.3615,"timestamp":1792753200,"date":"2026-10-23T11:00:00.000Z"},{"value":0.362,"timestamp":1792755000,"date":"2026-10-23T11:30:00.000Z"},{"value":0.3498,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"},{"value":0.3517,"timestamp":1792758600,"date":"2026-10-23T12:30:00.000Z"},{"value":0.3497,"timestamp":1792760400,"date":"2026-10-23T13:00:00.000Z"},{"value":0.3262,"timestamp":1792762200,"date":"2026-10-23T13:30:00.000Z"},{"value":0.3262,"timestamp":1792764000,"date":"2026-10-23T14:00:00.000Z"},{"value":0.3262,"timestamp":1792765800,"date":"2026-10-23T14:30:00.000Z"},{"value":0.3262,"timestamp":1792767600,"date":"2026-10-23T15:00:00.000Z"},{"value":0.3265,"timestamp":1792769400,"date":"2026-10-23T15:30:00.000Z"}],"change_percent":-0.10425240054869679},{"timeframe":"30d","prices":[{"value":0.3645,"timestamp":1792166400,"date":"2026-10-16T16:00:00.000Z"},{"value":0.3645,"timestamp":1792180800,"date":"2026-10-16T20:00:00.000Z"},{"value":0.3645,"timestamp":1792195200,"date":"2026-10-17T00:00:00.000Z"},{"value":0.3645,"timestamp":1792209600,"date":"2026-10-17T04:00:00.000Z"},{"value":0.3645,"timestamp":1792224000,"date":"2026-10-17T08:00:00.000Z"},{"value":0.3645,"timestamp":1792238400,"date":"2026-10-17T12:00:00.000Z"},{"value":0.3645,"timestamp":1792252800,"date":"2026-10-17T16:00:00.000Z"},{"value":0.3645,"timestamp":1792267200,"date":"2026-10-17T20:00:00.000Z"},{"value":0.3645,"timestamp":1792281600,"date":"2026-10-18T00:00:00.000Z"},{"value":0.3645,"timestamp":1792296000,"date":"2026-10-18T04:00:00.000Z"},{"value":0.3645,"timestamp":1792310400,"date":"2026-10-18T08:00:00.000Z"},{"value":0.3645,"timestamp":1792324800,"date":"2026-10-18T12:00:00.000Z"},{"value":0.3645,"timestamp":1792339200,"date":"2026-10-18T16:00:00.000Z"},{"value":0.3645,"timestamp":1792353600,"date":"2026-10-18T20:00:00.000Z"},{"value":0.3645,"timestamp":1792368000,"date":"2026-10-19T00:00:00.000Z"},{"value":0.3649,"timestamp":1792382400,"date":"2026-10-19T04:00:00.000Z"},{"value":0.3651,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.3652,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.3652,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.3674,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.3673,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.3673,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.3546,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.3734,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.3734,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.3734,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.3734,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.3734,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.3888,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.3751,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.3751,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.3751,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.3751,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.3708,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.3708,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.3708,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.3622,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.3622,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.3619,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.3619,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.362,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.3265,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"}],"change_percent":-0.10425240054869679},{"timeframe":"all","prices":[{"value":0.3645,"timestamp":1792166400,"date":"2026-10-16T16:00:00.000Z"},{"value":0.3645,"timestamp":1792180800,"date":"2026-10-16T20:00:00.000Z"},{"value":0.3645,"timestamp":1792195200,"date":"2026-10-17T00:00:00.000Z"},{"value":0.3645,"timestamp":1792209600,"date":"2026-10-17T04:00:00.000Z"},{"value":0.3645,"timestamp":1792224000,"date":"2026-10-17T08:00:00.000Z"},{"value":0.3645,"timestamp":1792238400,"date":"2026-10-17T12:00:00.000Z"},{"value":0.3645,"timestamp":1792252800,"date":"2026-10-17T16:00:00.000Z"},{"value":0.3645,"timestamp":1792267200,"date":"2026-10-17T20:00:00.000Z"},{"value":0.3645,"timestamp":1792281600,"date":"2026-10-18T00:00:00.000Z"},{"value":0.3645,"timestamp":1792296000,"date":"2026-10-18T04:00:00.000Z"},{"value":0.3645,"timestamp":1792310400,"date":"2026-10-18T08:00:00.000Z"},{"value":0.3645,"timestamp":1792324800,"date":"2026-10-18T12:00:00.000Z"},{"value":0.3645,"timestamp":1792339200,"date":"2026-10-18T16:00:00.000Z"},{"value":0.3645,"timestamp":1792353600,"date":"2026-10-18T20:00:00.000Z"},{"value":0.3645,"timestamp":1792368000,"date":"2026-10-19T00:00:00.000Z"},{"value":0.3649,"timestamp":1792382400,"date":"2026-10-19T04:00:00.000Z"},{"value":0.3651,"timestamp":1792396800,"date":"2026-10-19T08:00:00.000Z"},{"value":0.3652,"timestamp":1792411200,"date":"2026-10-19T12:00:00.000Z"},{"value":0.3652,"timestamp":1792425600,"date":"2026-10-19T16:00:00.000Z"},{"value":0.3674,"timestamp":1792440000,"date":"2026-10-19T20:00:00.000Z"},{"value":0.3673,"timestamp":1792454400,"date":"2026-10-20T00:00:00.000Z"},{"value":0.3673,"timestamp":1792468800,"date":"2026-10-20T04:00:00.000Z"},{"value":0.3546,"timestamp":1792483200,"date":"2026-10-20T08:00:00.000Z"},{"value":0.3734,"timestamp":1792497600,"date":"2026-10-20T12:00:00.000Z"},{"value":0.3734,"timestamp":1792512000,"date":"2026-10-20T16:00:00.000Z"},{"value":0.3734,"timestamp":1792526400,"date":"2026-10-20T20:00:00.000Z"},{"value":0.3734,"timestamp":1792540800,"date":"2026-10-21T00:00:00.000Z"},{"value":0.3734,"timestamp":1792555200,"date":"2026-10-21T04:00:00.000Z"},{"value":0.3888,"timestamp":1792569600,"date":"2026-10-21T08:00:00.000Z"},{"value":0.3751,"timestamp":1792584000,"date":"2026-10-21T12:00:00.000Z"},{"value":0.3751,"timestamp":1792598400,"date":"2026-10-21T16:00:00.000Z"},{"value":0.3751,"timestamp":1792612800,"date":"2026-10-21T20:00:00.000Z"},{"value":0.3751,"timestamp":1792627200,"date":"2026-10-22T00:00:00.000Z"},{"value":0.3708,"timestamp":1792641600,"date":"2026-10-22T04:00:00.000Z"},{"value":0.3708,"timestamp":1792656000,"date":"2026-10-22T08:00:00.000Z"},{"value":0.3708,"timestamp":1792670400,"date":"2026-10-22T12:00:00.000Z"},{"value":0.3622,"timestamp":1792684800,"date":"2026-10-22T16:00:00.000Z"},{"value":0.3622,"timestamp":1792699200,"date":"2026-10-22T20:00:00.000Z"},{"value":0.3619,"timestamp":1792713600,"date":"2026-10-23T00:00:00.000Z"},{"value":0.3619,"timestamp":1792728000,"date":"2026-10-23T04:00:00.000Z"},{"value":0.362,"timestamp":1792742400,"date":"2026-10-23T08:00:00.000Z"},{"value":0.3265,"timestamp":1792756800,"date":"2026-10-23T12:00:00.000Z"}],"change_percent":-0.10425240054869679}]}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]} \ No newline at end of file diff --git a/tests/samples/myriad_markets.json b/tests/samples/myriad_markets.json new file mode 100644 index 0000000..dccaaeb --- /dev/null +++ b/tests/samples/myriad_markets.json @@ -0,0 +1 @@ +{"data":[{"id":2385,"networkId":56,"slug":"will-the-clarity-act-be-signed-into-law-in-2026","title":"Will the Clarity Act be signed into law in 2026?","shortName":null,"ctaName":null,"description":"This market tracks whether the Digital Asset Market Clarity Act of 2025 (H.R. 3633), commonly known as the CLARITY Act, is passed by the U.S. Congress and signed into law.\n\n### **Market Dates:**\n\n- **Market Period:** From the publication date until December 31, 2026, at 11:59 PM ET.\n- **Market Close:** December 31, 2026, at 11:59 PM ET.\n- **Resolution Time:** Upon official confirmation that the bill has been signed into law, or after Market Close if it has not been.\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Digital Asset Market Clarity Act of 2025 (H.R. 3633) is passed by both chambers of the U.S. Congress and signed into law by December 31, 2026, 11:59 PM ET.\n- Resolves to \"**No**\" if the bill has not been passed by both chambers of Congress and signed into law by that time.\n\n### **Resolution Details:**\n\n- The primary resolution source for this market is [Congress.gov](http://Congress.gov)'s legislation tracker for H.R. 3633.\n- A consensus of other credible reporting from official U.S. government sources may also be used to resolve this market.\n- Amendments, renumbering, or renaming of the bill that preserve its substantive digital asset market structure provisions will not affect resolution, provided the legislation is signed into law within the Market Period.\n\n### **Cancellation Conditions:**\n\n- The U.S. Congress is not in session or unable to conduct legislative business for a prolonged period during the Market Period.\n- Any circumstance makes it impossible to determine the outcome definitively using the specified criteria.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-08-11T16:13:57.000Z","expiresAt":"2027-01-01T04:59:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto","Politics"],"tags":[],"resolutionSource":"https://www.congress.gov/bill/119th-congress/house-bill/3633","resolutionTitle":"Congress.gov","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/z85eIzs1VsJ5w7ofHsoqtqWeXCt-52VO6N7NY6FMUZs/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/lDuC5hnT-qgwbQgyGmsDthGODBclKveXtGgU3oychxE/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ea71b614-f663-477c-6fa6-b2134b576900/public","liquidity":895.43308999,"liquidityPrice":0,"volume":2794.42542057,"volume24h":22.9501272,"volumeNotional":5755.19708087,"volumeNotional24h":66.8449584,"users":30,"shares":1436.56494744,"featured":true,"featuredAt":"2026-09-15T15:31:55.843Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x45B3cc9Ac174b2BceEE5E489BE3C6c2782890739","0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xed5Ca4a7BF8102E73Ce5c3172D2f35F00ce151A6"],"outcomes":[{"id":0,"title":"Yes","shares":718.28247372,"sharesHeld":718.28247372,"price":0.045,"closingPrice":null,"priceChange24h":-0.1,"bestBid":0.03,"bestAsk":0.06,"imageUrl":null,"holders":10,"tokenId":"4770"},{"id":1,"title":"No","shares":718.28247372,"sharesHeld":718.28247372,"price":0.955,"closingPrice":null,"priceChange24h":0.00526316,"bestBid":0.94,"bestAsk":0.97,"imageUrl":null,"holders":5,"tokenId":"4771"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":"0x0000000000000000000000000000000000000000","name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[{"id":"ca1ff18a-10bd-4ce0-a8c0-166e13ab0bd6","providerName":"polymarket","externalMarketId":"1163699","externalMarketUrl":"https://polymarket.com/event/clarity-act-signed-into-law-in-2026","externalMarketTitle":"Clarity Act signed into law in 2026?"}]},{"id":3722,"networkId":56,"slug":"btc-price-on-october-25-4pm-utc-btc-above-80000-on-october-2","title":"BTC above $80,000 on October 25, 4PM UTC?","shortName":"Above 80,000","ctaName":null,"description":"### **Market Details:**\n\n- **Market Period:** From the publication date through October 25, 2026, at 4:00 PM UTC.\n- **Market Close:** October 25, 2026, at 4:00 PM UTC.\n- **Resolution Time:** October 25, 2026, at 4:00 PM UTC.\n\n### **Market Targets:**\n\n- This event contains multiple independent BTC/USDT Market Targets.\n- Each Market Target resolves independently as either \"**Yes**\" or \"**No**\".\n\n### **Resolution Criteria:**\n\n- Resolves to \"**Yes**\" if the Binance BTC/USDT spot price is above the respective Market Target at Resolution Time.\n- Resolves to \"**No**\" if the Binance BTC/USDT spot price is equal to or below the respective Market Target at Resolution Time.\n\n### **Resolution Details:**\n\n- Only the Binance BTC/USDT spot price chart will be considered, specifically:\n - **One minute candles (\"1m\")**\n - **\"Trading View\"**\n - **\"Close\" price**\n\n### **Cancellation Conditions:**\n\n- The BTC/USDT spot market on Binance is suspended or becomes unreliable.\n- The Binance platform is unavailable or experiences significant disruptions during the Market Period.\n- Any circumstance prevents reliable price tracking during the Market Period.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-19T10:02:13.000Z","expiresAt":"2026-10-25T16:00:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":176,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[3,7,10,14,18,21,25,28,32,36,39,43,46,50,54,57,61,64,68,72,75,79,82,86,90,93,97,100,104,108,111,115,118,122,126,129,133,136,140,144,147,151,154,158,162,165,169,172,176,180,176,172,169,165,162,158,154,151,147,144,140,136,133,129,126,122,118,115,111,108,104,100,97,93,90,86,82,79,75,72,68,64,61,57,54,50,46,43,39,36,32,28,25,21,18,14,10,7,3,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[{"slug":"crypto","type":"category","title":"Crypto","imageUrl":null},{"slug":"bitcoin","type":"asset","title":"Bitcoin","imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmYCnjf4GvwS86vx7frMFMzzkibic4Cju59jih2xeF3yVA/public"},{"slug":"above-price","type":"market-type","title":"Above price","imageUrl":null}],"resolutionSource":"https://www.binance.com/en/trade/BTC_USDT","resolutionTitle":"Binance","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":null,"bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/QmdQWWxwgU2ESMHBiABmZdmnZGK4twEC1yjVDu2M9efLtN/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/d7f0e922-d79a-4dd8-7f86-58ed8030cb00/public","liquidity":1092.43725892,"liquidityPrice":0,"volume":61.8740028,"volume24h":13.6705618,"volumeNotional":102.47097036,"volumeNotional24h":27.3411236,"users":3,"shares":32.34117036,"featured":true,"featuredAt":"2026-10-23T16:00:01.590Z","inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":1,"tradingModel":"ob","topHolders":["0x5632C31FeCB50F4b8968bc8CDc20357dC5E0c1Bd","0xd657025533626758DC5690DA581b28908df81955","0xfAaC195CaC01fD136A69f5640277A6B99DE3DD8F"],"outcomes":[{"id":0,"title":"Yes","shares":16.17058518,"sharesHeld":16.17058518,"price":0.91,"closingPrice":null,"priceChange24h":0.12345679,"bestBid":0.89,"bestAsk":0.93,"imageUrl":null,"holders":2,"tokenId":"7444"},{"id":1,"title":"No","shares":16.17058518,"sharesHeld":16.17058518,"price":0.09,"closingPrice":null,"priceChange24h":-0.52631579,"bestBid":0.07,"bestAsk":0.11,"imageUrl":null,"holders":1,"tokenId":"7445"}],"eventId":"2257269d-e465-4fef-ac63-96f4242b930e","outcomeIndex":0,"negRisk":false,"oracle":{"address":"0xd6a00f56955ac5aa9901b2a98e504a6d6811f2e5","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"feed":{"symbol":"BTCUSDT"},"rule":0,"param":"80000","yes_above":true,"data_source":"binance","binance_candle_interval":"1m"}},"scoreboard":null,"externalSources":[{"id":"ff95a6e8-d34f-434c-81b7-5b81cc124482","providerName":"polymarket","externalMarketId":"5889110","externalMarketUrl":"https://polymarket.com/event/bitcoin-above-on-october-25-2026","externalMarketTitle":"BTC above $80,000 on October 25, 4PM UTC?"}]},{"id":3785,"networkId":56,"slug":"patriots-vs-bears-who-wins","title":"Patriots vs. Bears: Who wins?","shortName":null,"ctaName":null,"description":"### **Market Dates:**\n\n- **Market Period:** From the publication date until October 24, 2026, at 00:15 UTC.\n- **Market Close:** October 24, 2026, at 00:15 UTC, 24 hours after the game is scheduled to start.\n- **Resolution Deadline:** The resolution will be determined after the results are published on the resolution source.\n\n### **Resolution Criteria:**\n\n- Resolves to “**Patriots**” if the New England Patriots win the game.\n- Resolves to “**Bears**” if the Chicago Bears win.\n\n### **Resolution Details:**\n\n- This market refers to the Week 7 game between the **New England Patriots** and the **Chicago Bears** scheduled to begin on October 22, 2026, at 8:15 PM ET.\n- If the game is postponed but played within the same “Week 7” window (NFL definition), the result still counts.\n- If the game ends in a tie, this market will resolve in favor of the team that scored last.\n\n### **Cancellation Conditions:**\n\n- The game is cancelled, postponed indefinitely, or held outside of the same “Week 7” window (NFL definition).\n- The game ends in a 0:0 tie.\n- Any circumstance makes it impossible to determine the outcome definitively.\n- The resolution source is suspended, becomes unreliable or fails to provide an outcome.\n\nIf the market is cancelled, participants can claim their stakes at the then-current market value of their shares, which could result in a profit or a loss depending on the price of those shares at cancellation.","publishedAt":"2026-10-22T00:15:00.000Z","expiresAt":"2026-10-24T00:15:00.000Z","resolvesAt":null,"fees":{"maker_fee_bps":0,"taker_fee_bps":73,"maker_fee_bps_array":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"taker_fee_bps_array":[1,3,4,6,7,9,10,12,13,15,16,18,19,21,22,24,25,27,28,30,31,33,34,36,37,39,40,42,43,45,46,48,49,51,52,54,55,57,58,60,61,63,64,66,67,69,70,72,73,75,73,72,70,69,67,66,64,63,61,60,58,57,55,54,52,51,49,48,46,45,43,42,40,39,37,36,34,33,31,30,28,27,25,24,22,21,19,18,16,15,13,12,10,9,7,6,4,3,1,1]},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Sports"],"tags":[{"slug":"american-football","type":"sport","title":"American Football","imageUrl":null},{"slug":"nfl","type":"league","title":"NFL","imageUrl":null},{"slug":"chicago-bears","type":"team","title":"Chicago Bears","imageUrl":null},{"slug":"new-england-patriots","type":"team","title":"New England Patriots","imageUrl":null},{"slug":"games","type":"market-type","title":"Games","imageUrl":null}],"resolutionSource":"https://www.espn.com/nfl/game/_/gameId/401873010/patriots-bears","resolutionTitle":"ESPN","token":{"name":"World Liberty Financial USD","address":"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d","symbol":"USD1","decimals":18},"imageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/-vPPbyr9zs3RKoWxZWcLBYNPWuAu5qS4rgzrZgmgr60/public","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/V_wYGG6t3Hhy7M7-GYCWcULcsd3gnezTk0WbM845O4Q/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/495498f4-7109-4539-2e81-9c88acfa9000/public","liquidity":0,"liquidityPrice":0,"volume":43.17460317,"volume24h":43.17460317,"volumeNotional":86.34920634,"volumeNotional24h":86.34920634,"users":4,"shares":86.34920634,"featured":true,"featuredAt":"2026-10-22T01:00:00.789Z","inPlay":true,"inPlayStartsAt":"2026-10-23T00:15:00.000Z","perpetual":false,"moneyline":true,"executionMode":1,"tradingModel":"ob","topHolders":["0x45B3cc9Ac174b2BceEE5E489BE3C6c2782890739","0x6831cD16d630300d302E7dc052D1F865ED43b40f","0x9fc4C1eaF6582c7371ad6810de5a976bC3F160ee"],"outcomes":[{"id":0,"title":"Patriots","shares":43.17460317,"sharesHeld":43.17460317,"price":0.01,"closingPrice":null,"priceChange24h":-0.97402597,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":2,"tokenId":"7570"},{"id":1,"title":"Bears","shares":43.17460317,"sharesHeld":43.17460317,"price":0.99,"closingPrice":null,"priceChange24h":0.6097561,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":2,"tokenId":"7571"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":"0xe5d08a4797c0bd67e1b6b0acb4730c12be7a1065","name":"Chainlink","url":"https://chain.link","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/chainlink.png","data":{"external_ref":"oddspapi:id1400003171515926:141:142"}},"scoreboard":{"type":"american-football","venue":{"name":"Soldier Field","location":"Chicago, IL, USA"},"status":"scheduled","outcome0":{"name":"Patriots","ctaName":"NE"},"outcome1":{"name":"Bears","ctaName":"CHI"},"updatedAt":"2026-10-22T13:15:44.329Z","statusDetail":"Pre-Game"},"externalSources":[{"id":"9d4fa2dd-328e-4023-9114-c1ff4e6ec683","providerName":"oddspapi","externalMarketId":"oddspapi:id1400003171515926:141:142","externalMarketUrl":null,"externalMarketTitle":"Chicago Bears vs New England Patriots — Winner (incl. overtime) — 2"},{"id":"e2a5ef53-8848-436b-a75c-105b90844e54","providerName":"polymarket","externalMarketId":"4465810","externalMarketUrl":null,"externalMarketTitle":"New England Patriots @ Chicago Bears: who wins?"}]}],"pagination":{"page":1,"limit":20,"total":3,"totalPages":1,"hasNext":false,"hasPrev":false}} \ No newline at end of file diff --git a/tests/samples/myriad_markets_amm.json b/tests/samples/myriad_markets_amm.json new file mode 100644 index 0000000..2524b76 --- /dev/null +++ b/tests/samples/myriad_markets_amm.json @@ -0,0 +1 @@ +{"data":[{"id":213,"networkId":2741,"slug":"up-or-down","title":"Up or Down?","shortName":null,"ctaName":null,"description":"Winning or losing? To be or not to be? *Up or Down?*\n\nQuestions that have been asked since the dawn of time, maybe even before. But is it more than a societal belief that pits us against eachother?\n\nTo begin grasping a potential path to an answer, we must first acknowledge there isn’t one. Like a caveman transported to Woodstock ‘69, we must attempt to understand the crowd that we are simultaneously part of and apart from—a crowd that behaves unpredictably based on predictable events and continues to surprise us while seemingly never changing.\n\nOnly then—once humbled, emboldened, and full of childlike wonder—we can ask our selves and the atoms around us the ever ringing question, the itch at the back of our brains, the perpetrator of too many sleepless nights: \\\\\n\\\\\n**Up or Down?**\n\n**Market Period:** Infinite. Do not give heed to any calendar dates, as they are but a mere distraction\n\n**Resolution Criteria:**\n\n- This market will not resolve\n\n**Resolution Details:**\n\n- No resolution can be triggered in this market\n\n**Cancelation (Invalidity) Conditions:**\n\nThis market will be canceled/invalid if:\n\n- Myriad Markets undergoes a changing of its contract that demands a cancelation of all active markets, or similar technical or business imperatives\n- Myriad Markets decides it is time for this market to cease to exist\n\nIn case of cancelation, participants will receive Myriad Points in accordance to the then current value of their positions.","publishedAt":"2025-08-05T17:44:50.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0,"treasury_fee":0,"distributor_fee":0},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0xBc30e9765Dc8c735206c76DE96d369754eBbcc1f","distributor":"0xBc30e9765Dc8c735206c76DE96d369754eBbcc1f"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Culture"],"tags":[],"resolutionSource":"https://x.com/myriadmarkets","resolutionTitle":"You","token":{"name":"Myriad PTS SZN 2","address":"0x0b07cf011B6e2b7E0803b892d97f751659940F23","symbol":"PTS","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmXSNGowG7p4FbBDNUGJG2zt5xW9YuTJSZHa7vE2hPYynD","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/569cdb2c-9fd4-49e4-9605-c286e9748f00/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/13d75b33-7103-486b-1782-c300daa65000/public","liquidity":100370011.16267617,"liquidityPrice":0.91706532,"volume":4705599612.084068,"volume24h":0,"volumeNotional":9846685904.390911,"volumeNotional24h":0,"users":12708,"shares":218893919.13344172,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0xBc30e9765Dc8c735206c76DE96d369754eBbcc1f","0xf0432800bC81565a0954C5CF1F5fb3514fb2b12a","0x33b99aa7673203CB2EaE2645a1EC7c5FDf7A2260"],"outcomes":[{"id":0,"title":"Up","shares":65806407.12287925,"sharesHeld":3344251229.5920887,"price":0.6993685,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmXSNGowG7p4FbBDNUGJG2zt5xW9YuTJSZHa7vE2hPYynD","holders":8508,"tokenId":"426"},{"id":1,"title":"Down","shares":153087512.01056245,"sharesHeld":3256970124.7044053,"price":0.3006315,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":3502,"tokenId":"427"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"id":412,"networkId":2741,"slug":"which-crypto-segment-has-the-most-upside","title":"Which crypto segment has the most upside?","shortName":null,"ctaName":null,"description":"**This market is a collective indicator of sentiment regarding the estimated potential growth of four Crypto and FinTech giants. This market is trading only and will never be resolved towards either option.**\n\n**Sentiment details:**\n\nThe Crypto and Financial Technlogy landscape is rapidly evolving, with four key innovations poised to reshape the industry: **AI Protocols, Perpetual Futures, Prediction Markets and Stablecoins.**\n\nTo know which one of them has the highest potential upside at any given moment might be the only information as valuable as the innovations themselves.\n\n**Market Details:**\n\n**Market Period:** Infinite.\n\n**Resolution Criteria:** This market will not resolve.\n\n**Resolution Details:** No resolution can be triggered in this market.\n\n**Cancelation (Invalidity) Conditions:**\n\nThis market will be canceled/invalid if:\n\n- Myriad Markets undergoes a change of its contract that demands a cancelation of all active markets, or similar technical or business imperatives.\n- Myriad Markets decides it is time for this market to cease to exist.\n\nIn the event of cancelation, participants may claim their stakes at the market value of their open positions at the time of cancelation. This could result in a profit or a loss, depending on the price of their outstanding shares.","publishedAt":"2025-09-25T19:33:28.000Z","expiresAt":"2100-01-01T00:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0,"treasury_fee":0,"distributor_fee":0},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0xBc30e9765Dc8c735206c76DE96d369754eBbcc1f","distributor":"0xBc30e9765Dc8c735206c76DE96d369754eBbcc1f"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Crypto"],"tags":[],"resolutionSource":"https://x.com/myriadmarkets","resolutionTitle":"You","token":{"name":"Myriad PTS SZN 2","address":"0x0b07cf011B6e2b7E0803b892d97f751659940F23","symbol":"PTS","decimals":18},"imageUrl":"https://cdn.polkamarkets.com/QmPdtur462wc2PTLtkkAsyAT3AzHJBNoewx1fXaLZEWGgE","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/ab8f6d25-a4bf-452f-620a-c30e99b4a700/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/726d44ac-c58e-42e9-b542-7583c89e2a00/public","liquidity":100000000.00013526,"liquidityPrice":0.54569313,"volume":1851852270.9901547,"volume24h":0,"volumeNotional":6355862294.595246,"volumeNotional24h":0,"users":8434,"shares":573562099.9394455,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":false,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x8eD09Ad3d59d0F63C5c54a7927e90914beEDA8F5","0x4841Ec2b14C129F03Ce6dcA3ce61f482D48DCE29","0x2A63f9ebe4Fc17AbBcb49E05E84dB9a8a612a786"],"outcomes":[{"id":0,"title":"Stablecoins","shares":158786441.00175616,"sharesHeld":414567245.92611945,"price":0.0859162,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmPdtur462wc2PTLtkkAsyAT3AzHJBNoewx1fXaLZEWGgE","holders":984,"tokenId":"824"},{"id":1,"title":"Prediction Markets","shares":17956874.72184432,"sharesHeld":555396812.2060313,"price":0.75972731,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":6307,"tokenId":"825"},{"id":2,"title":"AI Protocols","shares":132875687.65609294,"sharesHeld":440477999.2717827,"price":0.10266986,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":354,"tokenId":"826"},{"id":3,"title":"Perpetual Futures","shares":263943096.55975205,"sharesHeld":309410590.3681236,"price":0.05168663,"closingPrice":null,"priceChange24h":0,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":306,"tokenId":"827"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]},{"id":317,"networkId":2741,"slug":"up-or-down-73d55889-f062-4d2a-b4ac-901d64a261ee","title":"Up or Down?","shortName":null,"ctaName":null,"description":"**There are zero trading fees charged for this market.**\n\n**This market is a social sentiment experiment, is trading only, and will never be resolved towards either option.**\n\nWinning or losing? To be or not to be? *Up or Down?*\n\nQuestions that have been asked since the dawn of time, maybe even before. But is it more than a societal belief that pits us against each other?\n\nTo begin grasping a potential path to an answer, we must first acknowledge that there isn’t one. Like a caveman transported to Woodstock ‘69, we must attempt to understand the crowd that we are simultaneously part of and apart from—a crowd that behaves unpredictably based on predictable events and continues to surprise us while seemingly never changing.\n\nOnly then—once humbled, emboldened, and full of wonder—we can ask ourselves and the atoms around us the ever-ringing question, the itch at the back of our brains, the perpetrator of too many sleepless nights: **Up or Down?**\n\n**Market Period:** Infinite. Do not give heed to any calendar dates, as they are but a mere distraction\n\n**Resolution Criteria:**\n\n- This market will not resolve\n\n**Resolution Details:**\n\n- No resolution can be triggered in this market\n\n**Cancelation (Invalidity) Conditions:**\n\nThis market will be canceled/invalid if:\n\n- Myriad Markets undergoes a change of its contract that demands the cancelation of all active markets, or similar technical or business imperatives\n- Myriad Markets decides it is time for this market to cease to exist\n\n*In the event of cancelation, participants may claim their stakes at the market value of their open positions at the time of cancelation. This could result in a profit or a loss, depending on the price of their outstanding shares.*","publishedAt":"2025-09-06T15:25:24.000Z","expiresAt":"2100-12-31T23:01:00.000Z","resolvesAt":null,"fees":{"buy":{"fee":0,"treasury_fee":0,"distributor_fee":0},"sell":{"fee":0,"treasury_fee":0,"distributor_fee":0},"treasury":"0x5E3EbEc100e2294C0EB2264FC96225dF067AAaa3","distributor":"0xE44984C586FeBB31605D23b6316cA11B6f4D86b2"},"state":"open","voided":false,"resolvedOutcomeId":-1,"topics":["Sentiment"],"tags":[],"resolutionSource":"https://x.com/myriadmarkets","resolutionTitle":"You","token":{"name":"Bridged USDC (Stargate)","address":"0x84A71ccD554Cc1b02749b35d22F684CC8ec987e1","symbol":"USDC.e","decimals":6},"imageUrl":"https://cdn.polkamarkets.com/QmUw7i2DikGEqWbsWnYoiMZzHeY7ztsU39hAb9USWpWJjT","bannerImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/9130cf3b-d002-46e8-abd8-bdb9c499e400/public","ogImageUrl":"https://imagedelivery.net/YN1-rdnufJQJCgu3i1CbVw/a34a18f0-0490-4f85-7456-a571d6ff7300/public","liquidity":2135.95146,"liquidityPrice":0.94694285,"volume":17413084.702704,"volume24h":1,"volumeNotional":31901417.799565,"volumeNotional24h":1.513295,"users":4287,"shares":4512.447463,"featured":false,"featuredAt":null,"inPlay":false,"inPlayStartsAt":null,"perpetual":true,"moneyline":false,"executionMode":0,"tradingModel":"amm","topHolders":["0x4c0667Db0ba55cd81629D1A9bAa1c068962c2594","0x63e2FB6853719ec7f68DDB5Fd1Db16Adf04cc40d","0x8A611AEE71b6448a6F99B6001D1234d020f7d546"],"outcomes":[{"id":0,"title":"UP","shares":1530.239333,"sharesHeld":2696.8365,"price":0.66088484,"closingPrice":null,"priceChange24h":0.00022754,"bestBid":null,"bestAsk":null,"imageUrl":"https://cdn.polkamarkets.com/QmUw7i2DikGEqWbsWnYoiMZzHeY7ztsU39hAb9USWpWJjT","holders":187,"tokenId":"634"},{"id":1,"title":"DOWN","shares":2982.20813,"sharesHeld":1244.867703,"price":0.33911516,"closingPrice":null,"priceChange24h":-0.00044314,"bestBid":null,"bestAsk":null,"imageUrl":null,"holders":80,"tokenId":"635"}],"eventId":null,"outcomeIndex":null,"negRisk":false,"oracle":{"address":null,"name":"Myriad","url":"https://myriad.markets","image_url":"https://wp.decrypt.co/wp-content/uploads/2026/05/myriad.png","data":null},"scoreboard":null,"externalSources":[]}],"pagination":{"page":1,"limit":3,"total":18,"totalPages":6,"hasNext":true,"hasPrev":false}} \ No newline at end of file diff --git a/tests/samples/myriad_not_found.json b/tests/samples/myriad_not_found.json new file mode 100644 index 0000000..d90a1e7 --- /dev/null +++ b/tests/samples/myriad_not_found.json @@ -0,0 +1 @@ +{"error":"Market not found"} \ No newline at end of file diff --git a/tests/samples/myriad_quote_buy.json b/tests/samples/myriad_quote_buy.json new file mode 100644 index 0000000..53906e4 --- /dev/null +++ b/tests/samples/myriad_quote_buy.json @@ -0,0 +1 @@ +{"value":25,"shares":36.95425873627248,"shares_threshold":36.769487442591114,"price_average":0.6765120139038598,"price_before":0.67371743,"price_after":0.6792940605271434,"calldata":"0x1281311d000000000000000000000000000000000000000000000000000000000001d6990000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001fe47760414d4623f0000000000000000000000000000000000000000000000015af1d78b58c40000","net_amount":25,"fees":{"treasury":0,"distributor":0,"fee":0}} \ No newline at end of file diff --git a/tests/samples/myriad_quote_sell.json b/tests/samples/myriad_quote_sell.json new file mode 100644 index 0000000..2816a3d --- /dev/null +++ b/tests/samples/myriad_quote_sell.json @@ -0,0 +1 @@ +{"value":13.443891242494558,"shares":20,"shares_threshold":13.376671786282085,"price_average":0.672194562124728,"price_before":0.67371743,"price_after":0.6706680666622662,"calldata":"0x3620875e000000000000000000000000000000000000000000000000000000000001d6990000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ba924bdb33df810500000000000000000000000000000000000000000000000116f18b817159f7ac","net_amount":13.443891242494558,"fees":{"treasury":0,"distributor":0,"fee":0}} \ No newline at end of file diff --git a/tests/samples/myriad_trades.json b/tests/samples/myriad_trades.json new file mode 100644 index 0000000..579a54d --- /dev/null +++ b/tests/samples/myriad_trades.json @@ -0,0 +1 @@ +[{"price":"0.03","priceAfterFees":"0.03","amount":"22.28160000","side":"buy","outcome":0,"txHash":"0xc3906b47dcc7713006f962a7b7c5570cdb5bbfb6760c4d58a4693362b0097dd8","timestamp":1792755685,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.03","priceAfterFees":"0.02996999991023983915","amount":"22.28160000","side":"sell","outcome":0,"txHash":"0xc3906b47dcc7713006f962a7b7c5570cdb5bbfb6760c4d58a4693362b0097dd8","timestamp":1792755685,"fees":{"total":"0.00066845","lp":"0","treasury":"0.00066845","distributor":"0"}},{"price":"0.0449999998204803164","priceAfterFees":"0.0449999998204803164","amount":"22.28167920","side":"merge","outcome":0,"txHash":"0x6c16a7c8b6abaf7ad35291fff725ddcf02f07c0d0f360f32c29643d9f9c681ad","timestamp":1792755671,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.9549999997307204746","priceAfterFees":"0.9549999997307204746","amount":"22.28167920","side":"merge","outcome":1,"txHash":"0x6c16a7c8b6abaf7ad35291fff725ddcf02f07c0d0f360f32c29643d9f9c681ad","timestamp":1792755671,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.05000000004156249999","priceAfterFees":"0.05009000031029731245","amount":"12.03007519","side":"buy","outcome":0,"txHash":"0x5ebcf909eab51cc40e6d6fc2362b1c176c837a829bbaeb740b49b0188b77337d","timestamp":1792755668,"fees":{"total":"0.00108271","lp":"0","treasury":"0.00108271","distributor":"0"}},{"price":"0.94000000005852742648","priceAfterFees":"0.94000000005852742648","amount":"10.25160401","side":"buy","outcome":1,"txHash":"0x5ebcf909eab51cc40e6d6fc2362b1c176c837a829bbaeb740b49b0188b77337d","timestamp":1792755668,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.05999999994147257352","priceAfterFees":"0.0601259997361134904","amount":"10.25160401","side":"buy","outcome":0,"txHash":"0x5ebcf909eab51cc40e6d6fc2362b1c176c837a829bbaeb740b49b0188b77337d","timestamp":1792755668,"fees":{"total":"0.0012917","lp":"0","treasury":"0.0012917","distributor":"0"}},{"price":"0.94999999995843750001","priceAfterFees":"0.94999999995843750001","amount":"12.03007519","side":"buy","outcome":1,"txHash":"0x5ebcf909eab51cc40e6d6fc2362b1c176c837a829bbaeb740b49b0188b77337d","timestamp":1792755668,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.05500000011234146345","priceAfterFees":"0.05500000011234146345","amount":"16.02257924","side":"merge","outcome":0,"txHash":"0x1bfc9fd856654fd42300d6a379cbb5fcb462fa867414bf6fd9d23a384b8779f3","timestamp":1792259119,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.94500000051177777792","priceAfterFees":"0.94500000051177777792","amount":"16.02257924","side":"merge","outcome":1,"txHash":"0x1bfc9fd856654fd42300d6a379cbb5fcb462fa867414bf6fd9d23a384b8779f3","timestamp":1792259119,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.93000000183722472063","priceAfterFees":"0.93000000183722472063","amount":"3.86452453","side":"buy","outcome":1,"txHash":"0xca7d0d6ee6b4b7c8963a5897d7010c2942f62b5da88ed3f1ff7aefbef4acd863","timestamp":1792259098,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.07000000075041573096","priceAfterFees":"0.070175000286516489","amount":"3.86452453","side":"buy","outcome":0,"txHash":"0xca7d0d6ee6b4b7c8963a5897d7010c2942f62b5da88ed3f1ff7aefbef4acd863","timestamp":1792259098,"fees":{"total":"0.00067629","lp":"0","treasury":"0.00067629","distributor":"0"}},{"price":"0.94000000021385000002","priceAfterFees":"0.94000000021385000002","amount":"12.15805471","side":"buy","outcome":1,"txHash":"0xca7d0d6ee6b4b7c8963a5897d7010c2942f62b5da88ed3f1ff7aefbef4acd863","timestamp":1792259098,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.05999999978614999998","priceAfterFees":"0.06012599938366291494","amount":"12.15805471","side":"buy","outcome":0,"txHash":"0xca7d0d6ee6b4b7c8963a5897d7010c2942f62b5da88ed3f1ff7aefbef4acd863","timestamp":1792259098,"fees":{"total":"0.00153191","lp":"0","treasury":"0.00153191","distributor":"0"}},{"price":"0.06","priceAfterFees":"0.06","amount":"19.31000000","side":"sell","outcome":0,"txHash":"0x3b54bdbad76a127b67402c5419805a34d18a07efe023f67b28f077bb5ce7cc1c","timestamp":1791824220,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.06","priceAfterFees":"0.060126","amount":"19.31000000","side":"buy","outcome":0,"txHash":"0x3b54bdbad76a127b67402c5419805a34d18a07efe023f67b28f077bb5ce7cc1c","timestamp":1791824220,"fees":{"total":"0.00243306","lp":"0","treasury":"0.00243306","distributor":"0"}},{"price":"0.05","priceAfterFees":"0.05","amount":"3.37760000","side":"merge","outcome":0,"txHash":"0xb70d0c38d01e31f306ed52b6d59004b30724d7ccfc5d020367109a7ea8c0688d","timestamp":1791484934,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.95","priceAfterFees":"0.95","amount":"3.37760000","side":"merge","outcome":1,"txHash":"0xb70d0c38d01e31f306ed52b6d59004b30724d7ccfc5d020367109a7ea8c0688d","timestamp":1791484934,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.93999999517217948373","priceAfterFees":"0.93999999517217948373","amount":"0.53854529","side":"buy","outcome":1,"txHash":"0x09ee3cf5255686e35a6e1ec0efb22c62f25582e03f5159470a94931002e5684f","timestamp":1791484900,"fees":{"total":"0","lp":"0","treasury":"0","distributor":"0"}},{"price":"0.93999999517217948373","priceAfterFees":"0.93802599220578087314","amount":"0.53854529","side":"sell","outcome":1,"txHash":"0x09ee3cf5255686e35a6e1ec0efb22c62f25582e03f5159470a94931002e5684f","timestamp":1791484900,"fees":{"total":"0.00106309","lp":"0","treasury":"0.00106309","distributor":"0"}}] \ No newline at end of file diff --git a/tests/samples/myriad_ws.json b/tests/samples/myriad_ws.json new file mode 100644 index 0000000..4d39b70 --- /dev/null +++ b/tests/samples/myriad_ws.json @@ -0,0 +1,24 @@ +[ + "{\"id\":1,\"connect\":{\"client\":\"96b5c231-cccd-4562-9582-f4df0409a5d0\",\"version\":\"6.7.2 OSS\",\"ping\":25,\"pong\":true}}", + "{\"id\":2,\"subscribe\":{}}\n{\"id\":3,\"subscribe\":{}}\n{\"id\":4,\"subscribe\":{}}\n{\"id\":5,\"subscribe\":{}}\n{\"id\":6,\"subscribe\":{}}\n{\"id\":7,\"subscribe\":{}}", + "{\"id\":8,\"subscribe\":{}}\n{\"id\":9,\"subscribe\":{}}\n{\"id\":10,\"subscribe\":{}}", + "{\"id\":11,\"subscribe\":{}}", + "{\"push\":{\"channel\":\"orderbook:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"changes\":[{\"outcome\":1,\"side\":\"bid\",\"price\":\"610000000000000000\",\"amount\":\"28103044496487120000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"390000000000000000\",\"amount\":\"28103044496487120000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"330000000000000000\",\"amount\":\"0\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"670000000000000000\",\"amount\":\"0\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"320000000000000000\",\"amount\":\"35714285714285710000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"680000000000000000\",\"amount\":\"35714285714285710000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"600000000000000000\",\"amount\":\"19047619047619047000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"400000000000000000\",\"amount\":\"19047619047619047000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"310000000000000000\",\"amount\":\"55299539170506910000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"690000000000000000\",\"amount\":\"55299539170506910000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"590000000000000000\",\"amount\":\"0\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"410000000000000000\",\"amount\":\"0\"}],\"ts\":1792772635587},\"offset\":1315}}}", + "{\"push\":{\"channel\":\"orderbook:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"changes\":[{\"outcome\":0,\"side\":\"bid\",\"price\":\"290000000000000000\",\"amount\":\"59113300492610840000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"710000000000000000\",\"amount\":\"59113300492610840000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"620000000000000000\",\"amount\":\"36866359447004605000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"380000000000000000\",\"amount\":\"36866359447004605000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"640000000000000000\",\"amount\":\"17857142857142854000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"360000000000000000\",\"amount\":\"17857142857142854000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"600000000000000000\",\"amount\":\"19047619047619047000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"400000000000000000\",\"amount\":\"19047619047619047000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"630000000000000000\",\"amount\":\"27210884353741494000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"370000000000000000\",\"amount\":\"27210884353741494000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"610000000000000000\",\"amount\":\"28103044496487120000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"390000000000000000\",\"amount\":\"28103044496487120000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"300000000000000000\",\"amount\":\"76190476190476189000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"700000000000000000\",\"amount\":\"76190476190476189000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"320000000000000000\",\"amount\":\"35714285714285710000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"680000000000000000\",\"amount\":\"35714285714285710000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"280000000000000000\",\"amount\":\"40816326530612240000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"720000000000000000\",\"amount\":\"40816326530612240000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"310000000000000000\",\"amount\":\"55299539170506910000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"690000000000000000\",\"amount\":\"55299539170506910000\"}],\"ts\":1792772635588},\"offset\":1316}}}", + "{\"push\":{\"channel\":\"prices:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"outcomes\":[{\"outcome\":0,\"bestBid\":\"320000000000000000\",\"bestAsk\":\"360000000000000000\",\"last\":\"340000000000000000\"},{\"outcome\":1,\"bestBid\":\"640000000000000000\",\"bestAsk\":\"680000000000000000\",\"last\":\"660000000000000000\"}],\"ts\":1792772635595},\"offset\":658}}}", + "{\"push\":{\"channel\":\"orderbook:56:3724\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3724,\"changes\":[{\"outcome\":0,\"side\":\"bid\",\"price\":\"20000000000000000\",\"amount\":\"0\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"980000000000000000\",\"amount\":\"0\"}],\"ts\":1792772642647},\"offset\":3}}}", + "{}", + "{}", + "{\"push\":{\"channel\":\"orderbook:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"changes\":[{\"outcome\":1,\"side\":\"bid\",\"price\":\"620000000000000000\",\"amount\":\"18433179723502302000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"380000000000000000\",\"amount\":\"18433179723502302000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"600000000000000000\",\"amount\":\"4761904761904762000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"400000000000000000\",\"amount\":\"4761904761904762000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"610000000000000000\",\"amount\":\"0\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"390000000000000000\",\"amount\":\"0\"}],\"ts\":1792772686320},\"offset\":1317}}}", + "{\"push\":{\"channel\":\"orderbook:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"changes\":[{\"outcome\":1,\"side\":\"bid\",\"price\":\"650000000000000000\",\"amount\":\"26373626373626372000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"350000000000000000\",\"amount\":\"26373626373626372000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"620000000000000000\",\"amount\":\"18433179723502302000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"380000000000000000\",\"amount\":\"18433179723502302000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"660000000000000000\",\"amount\":\"17316017316017312000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"340000000000000000\",\"amount\":\"17316017316017312000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"640000000000000000\",\"amount\":\"35714285714285708000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"360000000000000000\",\"amount\":\"35714285714285708000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"600000000000000000\",\"amount\":\"4761904761904762000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"400000000000000000\",\"amount\":\"4761904761904762000\"}],\"ts\":1792772686322},\"offset\":1318}}}", + "{\"push\":{\"channel\":\"prices:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"outcomes\":[{\"outcome\":0,\"bestBid\":\"320000000000000000\",\"bestAsk\":\"340000000000000000\",\"last\":\"330000000000000000\"},{\"outcome\":1,\"bestBid\":\"660000000000000000\",\"bestAsk\":\"680000000000000000\",\"last\":\"670000000000000000\"}],\"ts\":1792772686324},\"offset\":659}}}", + "{}", + "{\"push\":{\"channel\":\"orderbook:56:3722\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3722,\"changes\":[{\"outcome\":1,\"side\":\"bid\",\"price\":\"90000000000000000\",\"amount\":\"126984126984126970000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"910000000000000000\",\"amount\":\"126984126984126970000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"70000000000000000\",\"amount\":\"326530612244897900000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"930000000000000000\",\"amount\":\"326530612244897900000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"80000000000000000\",\"amount\":\"214285714285714280000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"920000000000000000\",\"amount\":\"214285714285714280000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"10000000000000000\",\"amount\":\"594339622641509550000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"990000000000000000\",\"amount\":\"594339622641509550000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"10000000000000000\",\"amount\":\"66037735849056622000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"990000000000000000\",\"amount\":\"66037735849056622000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"50000000000000000\",\"amount\":\"67924528301886791130\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"950000000000000000\",\"amount\":\"67924528301886791130\"}],\"ts\":1792772707914},\"offset\":1}}}", + "{\"push\":{\"channel\":\"orderbook:56:3722\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3722,\"changes\":[{\"outcome\":1,\"side\":\"bid\",\"price\":\"40000000000000000\",\"amount\":\"0\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"960000000000000000\",\"amount\":\"0\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"30000000000000000\",\"amount\":\"0\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"970000000000000000\",\"amount\":\"0\"}],\"ts\":1792772707914},\"offset\":2}}}", + "{\"push\":{\"channel\":\"prices:56:3722\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3722,\"outcomes\":[{\"outcome\":0,\"bestBid\":\"890000000000000000\",\"bestAsk\":\"910000000000000000\",\"last\":\"900000000000000000\"},{\"outcome\":1,\"bestBid\":\"90000000000000000\",\"bestAsk\":\"110000000000000000\",\"last\":\"100000000000000000\"}],\"ts\":1792772707916},\"offset\":1}}}", + "{\"push\":{\"channel\":\"orderbook:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"changes\":[{\"outcome\":0,\"side\":\"bid\",\"price\":\"330000000000000000\",\"amount\":\"34632034632034625000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"670000000000000000\",\"amount\":\"34632034632034625000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"620000000000000000\",\"amount\":\"27649769585253455000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"380000000000000000\",\"amount\":\"27649769585253455000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"610000000000000000\",\"amount\":\"18735362997658080000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"390000000000000000\",\"amount\":\"18735362997658080000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"310000000000000000\",\"amount\":\"73732718894009210000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"690000000000000000\",\"amount\":\"73732718894009210000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"640000000000000000\",\"amount\":\"26785714285714285000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"360000000000000000\",\"amount\":\"26785714285714285000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"290000000000000000\",\"amount\":\"39408866995073886000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"710000000000000000\",\"amount\":\"39408866995073886000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"300000000000000000\",\"amount\":\"57142857142857146000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"700000000000000000\",\"amount\":\"57142857142857146000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"650000000000000000\",\"amount\":\"17582417582417580000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"350000000000000000\",\"amount\":\"17582417582417580000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"630000000000000000\",\"amount\":\"36281179138321988000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"370000000000000000\",\"amount\":\"36281179138321988000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"320000000000000000\",\"amount\":\"53571428571428570000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"680000000000000000\",\"amount\":\"53571428571428570000\"}],\"ts\":1792772721368},\"offset\":1319}}}", + "{\"push\":{\"channel\":\"orderbook:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"changes\":[{\"outcome\":1,\"side\":\"bid\",\"price\":\"650000000000000000\",\"amount\":\"17582417582417580000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"350000000000000000\",\"amount\":\"17582417582417580000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"300000000000000000\",\"amount\":\"57142857142857146000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"700000000000000000\",\"amount\":\"57142857142857146000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"290000000000000000\",\"amount\":\"39408866995073886000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"710000000000000000\",\"amount\":\"39408866995073886000\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"660000000000000000\",\"amount\":\"0\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"340000000000000000\",\"amount\":\"0\"},{\"outcome\":1,\"side\":\"bid\",\"price\":\"640000000000000000\",\"amount\":\"26785714285714285000\"},{\"outcome\":0,\"side\":\"ask\",\"price\":\"360000000000000000\",\"amount\":\"26785714285714285000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"280000000000000000\",\"amount\":\"0\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"720000000000000000\",\"amount\":\"0\"}],\"ts\":1792772721368},\"offset\":1320}}}", + "{\"push\":{\"channel\":\"prices:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"outcomes\":[{\"outcome\":0,\"bestBid\":\"330000000000000000\",\"bestAsk\":\"350000000000000000\",\"last\":\"340000000000000000\"},{\"outcome\":1,\"bestBid\":\"650000000000000000\",\"bestAsk\":\"670000000000000000\",\"last\":\"660000000000000000\"}],\"ts\":1792772721396},\"offset\":660}}}", + "{}", + "{\"push\":{\"channel\":\"orderbook:56:3723\",\"pub\":{\"data\":{\"networkId\":56,\"marketId\":3723,\"changes\":[{\"outcome\":0,\"side\":\"bid\",\"price\":\"330000000000000000\",\"amount\":\"0\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"670000000000000000\",\"amount\":\"0\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"310000000000000000\",\"amount\":\"36866359447004605000\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"690000000000000000\",\"amount\":\"36866359447004605000\"},{\"outcome\":0,\"side\":\"bid\",\"price\":\"320000000000000000\",\"amount\":\"0\"},{\"outcome\":1,\"side\":\"ask\",\"price\":\"680000000000000000\",\"amount\":\"0\"}],\"ts\":1792772730162},\"offset\":1321}}}" +] \ No newline at end of file diff --git a/tests/test_init.py b/tests/test_init.py index 775d89a..d1f1e49 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -41,6 +41,7 @@ def test_it_writes_every_venue_asked_for_readable_by_the_owner_only(tmp_path: Pa "n", # predict.fun "n", # Limitless "n", # SX Bet + "n", # Myriad "n", # Betfair "n"], # Smarkets secrets=[KEY, "us-secret"], @@ -64,7 +65,7 @@ def test_wrong_values_are_asked_again_with_the_reason(tmp_path: Path): p, said = scripted( ["y", "", "kid-1", str(tmp_path / "missing.pem"), str(not_pem), str(pem), "staging", "demo", "y", "0x1234", WALLET, "7", "1", - "n", "n", "n", "n", "n", "n", "n", "n"], + "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -81,7 +82,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) env = tmp_path / ".env" env.write_text("# mine\nSOMETHING_ELSE=keep\nPOLYMARKET_US_KEY_ID=old\n") os.chmod(env, 0o644) - p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) + p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) assert run(env, p) == 0 text = env.read_text() assert "# mine" in text and "SOMETHING_ELSE=keep" in text @@ -91,7 +92,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) def test_saying_no_to_everything_writes_nothing(tmp_path: Path): - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) assert run(tmp_path / ".env", p) == 0 assert not (tmp_path / ".env").exists() and any("Nothing changed." in line for line in said) @@ -104,7 +105,7 @@ def test_merge_quotes_values_with_spaces_and_updates_in_place(): @pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) + p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) assert run(tmp_path / ".env", p) == 0 assert (tmp_path / ".gitignore").read_text() == ".env\n" ignored = subprocess.run(["git", "check-ignore", "-q", ".env"], cwd=tmp_path) @@ -113,7 +114,7 @@ def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): def test_opinion_takes_a_key_it_is_given(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["OPINION_PRIVATE_KEY"] == KEY and values["OPINION_API_KEY"] == "opinion-api-key" @@ -122,7 +123,7 @@ def test_opinion_takes_a_key_it_is_given(tmp_path: Path): def test_opinion_with_an_api_key_needs_no_private_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"OPINION_API_KEY": "opinion-api-key"} assert load_credentials({}, dotenv=env, redact_logs=False)["opinion"] is None, "not enough to trade" @@ -135,7 +136,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch signed_with: list[str] = [] monkeypatch.setattr(opinion, "create_api_key", lambda key: signed_with.append(key) or "created-key") - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) assert run(tmp_path / ".env", p) == 0 assert signed_with == [KEY], "creating a key needs the private key: an empty one is asked again" assert read_dotenv(tmp_path / ".env")["OPINION_API_KEY"] == "created-key" @@ -144,7 +145,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY and values["HYPERLIQUID_ACCOUNT_ADDRESS"] == WALLET @@ -156,7 +157,7 @@ def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY @@ -165,7 +166,7 @@ def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_PRIVATE_KEY"] == KEY and values["PREDICT_FUN_ACCOUNT_ADDRESS"] == WALLET @@ -178,7 +179,7 @@ def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"PREDICT_FUN_API_KEY": "pred_sk_test"} text = " ".join(said) @@ -190,7 +191,7 @@ def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_TESTNET"] == "1" and "PREDICT_FUN_API_KEY" not in values @@ -201,7 +202,7 @@ def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): env = tmp_path / ".env" secret = "c2VjcmV0LWJ5dGVz" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n"], secrets=[KEY[2:], secret]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) assert run(env, p) == 0 values = read_dotenv(env) assert values["LIMITLESS_PRIVATE_KEY"] == KEY and values["LIMITLESS_API_TOKEN_ID"] == "token-123" @@ -212,7 +213,7 @@ def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n"], secrets=["sx-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["SX_BET_PRIVATE_KEY"] == KEY and values["SX_BET_API_KEY"] == "sx-api-key" @@ -223,7 +224,7 @@ def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n"], secrets=["sx-api-key", ""]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key", "SX_BET_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said), "a key pasted as the address is refused" @@ -234,7 +235,7 @@ def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): def test_sx_bet_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n"], secrets=["sx-api-key", ""]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key"} loaded = load_credentials({}, dotenv=env, redact_logs=False)["sx_bet"] @@ -248,3 +249,31 @@ def test_an_api_key_alone_counts_as_set_up_already(tmp_path: Path): p = Prompter(ask=lambda question: asked.append(question) or "n", ask_secret=lambda _q: "", say=lambda _: None) assert run(env, p) == 0 assert any("Set up SX Bet (already set)?" in question for question in asked) + + +def test_myriad_takes_an_api_key_and_the_wallet(tmp_path: Path): + env = tmp_path / ".env" + p, _ = scripted(["n"] * 8 + ["y", "key-1", "n", "n"], secrets=["secret-1", KEY[2:]]) + assert run(env, p) == 0 + values = read_dotenv(env) + assert values == {"MYRIAD_API_KEY": "key-1", "MYRIAD_API_SECRET": "secret-1", "MYRIAD_PRIVATE_KEY": KEY} + loaded = load_credentials({}, dotenv=env, redact_logs=False)["myriad"] + assert loaded is not None and loaded.api_key == "key-1" and loaded.private_key == KEY + + +def test_myriad_without_an_api_key_signs_in_with_the_wallet(tmp_path: Path): + env = tmp_path / ".env" + p, said = scripted(["n"] * 8 + ["y", "", "n", "n"], secrets=[KEY]) + assert run(env, p) == 0 + assert read_dotenv(env) == {"MYRIAD_PRIVATE_KEY": KEY} + assert any("signs in for a session" in line for line in said) + + +def test_myriad_with_only_an_api_key_needs_the_wallet_address(tmp_path: Path): + env = tmp_path / ".env" + p, said = scripted(["n"] * 8 + ["y", "key-1", KEY, WALLET, "n", "n"], secrets=["secret-1", ""]) + assert run(env, p) == 0 + assert read_dotenv(env) == {"MYRIAD_API_KEY": "key-1", "MYRIAD_API_SECRET": "secret-1", "MYRIAD_ACCOUNT_ADDRESS": WALLET} + assert any("That looks like a private key" in line for line in said) + loaded = load_credentials({}, dotenv=env, redact_logs=False)["myriad"] + assert loaded is not None and loaded.private_key is None and loaded.address == WALLET diff --git a/tests/test_myriad.py b/tests/test_myriad.py new file mode 100644 index 0000000..977bb7d --- /dev/null +++ b/tests/test_myriad.py @@ -0,0 +1,346 @@ +"""Myriad normalizers and adapter, against payloads recorded from the live venue.""" +from __future__ import annotations + +import httpx +import pytest + +from synpath import BadRequest, MarketNotFound +from synpath.myriad import ( + Myriad, amm_book_levels, amm_order_book, candles_from_chart, chart_for, fee_schedule_of, markets_of, + normalize_amm_trade, normalize_event, normalize_market, normalize_order_book, normalize_trades, parse_id, + status_of, units, +) +from synpath.trading.myriad_signing import request_signature + +from conftest import load + +MARKETS = load("myriad_markets.json")["data"] +STANDALONE, CHILD, SPORTS = MARKETS +AMM_MARKETS = load("myriad_markets_amm.json")["data"] +MARKET = load("myriad_market.json") +AMM = load("myriad_market_amm.json") +GROUPED = load("myriad_grouped.json") +EVENT = load("myriad_event.json") +TRADES = load("myriad_trades.json") + + +class FakeHttp: + """Answers GETs by path from recorded payloads, and records them.""" + + def __init__(self, answers): + self.calls: list[tuple[str, object]] = [] + self.answers = answers + + def get(self, path, params=None): + self.calls.append((path, params)) + if path in self.answers: + answer = self.answers[path] + return answer(params) if callable(answer) else answer + raise MarketNotFound(f"myriad: no {path}") + + def close(self): + pass + + +def venue_with(answers): + venue = Myriad(limiter=None) + venue.http = FakeHttp(answers) + return venue + + +class TestIds: + def test_a_market_is_its_network_kind_and_number(self): + assert parse_id("56-2385").market == 2385 and not parse_id("56-2385").amm + amm = parse_id("56-amm-2385") + assert amm.amm and amm.network == 56 and amm.native == "56-amm-2385" + outcome = parse_id("2741-amm-412-3") + assert outcome.outcome == 3 and outcome.market_native == "2741-amm-412" + assert parse_id("patriots-vs-bears-who-wins") is None, "a slug is not an id" + + def test_an_order_book_market_has_no_outcome_suffix(self): + with pytest.raises(BadRequest, match="binary"): + parse_id("56-2385-1") + + def test_the_same_number_names_two_markets_on_bnb_chain(self): + """Order-book market 2385 and AMM market 2385 are different markets.""" + ob = normalize_market(STANDALONE) + assert ob.id == "myriad:56-2385" and ob.info["trading_model"] == "ob" + amm = normalize_market({**AMM, "id": 2385}) + assert amm.id == "myriad:56-amm-2385" + + +class TestMarket: + def test_an_order_book_market_is_yes_first(self): + market = normalize_market(STANDALONE) + yes, no = STANDALONE["outcomes"] + assert market.yes.label == yes["title"] and market.no.label == no["title"] + assert market.yes.quote.bid == yes["bestBid"] and market.yes.quote.ask == yes["bestAsk"] + assert market.no.quote.bid == no["bestBid"] and market.no.quote.ask == no["bestAsk"] + assert market.yes.venue_token_id == yes["tokenId"] and market.tick_size == 0.01 + assert market.book_model == "shared_complement" and market.event_id == market.id + assert market.stats.volume_24h == STANDALONE["volume24h"] and market.stats.volume_unit == "collateral" + assert market.url == f"https://myriad.markets/markets/{STANDALONE['slug']}" + assert {"crypto", "politics"} <= set(market.tags) + + def test_an_event_child_carries_its_short_name(self): + market = normalize_market(CHILD) + assert market.event_id == f"myriad:{CHILD['eventId']}" + assert market.outcome_label == CHILD["shortName"] and market.title == CHILD["title"] + assert market.settlement_sources[0]["name"] == CHILD["oracle"]["name"] + + def test_a_sports_market_names_its_teams_and_scoreboard(self): + market = normalize_market(SPORTS) + assert (market.yes.label, market.no.label) == ("Patriots", "Bears") + assert market.category == "sports" and market.info["scoreboard"]["type"] == "american-football" + assert market.yes.quote.mid is None, "no book, no mid" + + def test_the_polymarket_market_it_names_is_kept(self): + linked = [normalize_market(m) for m in MARKETS + [MARKET] if m.get("externalSources")] + assert linked and all(m.info["polymarket_ids"] for m in linked) + + def test_an_amm_market_quotes_its_pool_price(self): + market = normalize_market(AMM) + price = AMM["outcomes"][0]["price"] + assert market.id == "myriad:56-amm-120473" and market.tick_size is None + assert market.yes.quote.mid == pytest.approx(price, abs=1e-6) + assert market.yes.quote.bid == market.yes.quote.ask == market.yes.quote.mid, "a fee-free pool" + + def test_an_amm_fee_widens_the_quote(self): + fees = {"buy": {"fee": 0.01, "treasury_fee": 0, "distributor_fee": 0.01}, + "sell": {"fee": 0.01, "treasury_fee": 0, "distributor_fee": 0}} + quote = normalize_market({**AMM, "fees": fees}).yes.quote + assert quote.bid < quote.mid < quote.ask + assert quote.ask == pytest.approx(quote.mid / 0.98, abs=1e-6) and quote.bid == pytest.approx(quote.mid * 0.99, abs=1e-6) + + def test_a_many_outcome_amm_market_is_an_event_of_binary_markets(self): + many = AMM_MARKETS[1] + markets = markets_of(many) + assert [m.id for m in markets] == [f"myriad:2741-amm-412-{i}" for i in range(4)] + assert all(m.event_id == "myriad:2741-amm-412" for m in markets) + first = markets[1] + assert first.yes.label == "Prediction Markets" and first.no.label == "Not Prediction Markets" + assert first.yes.quote.mid + first.no.quote.mid == pytest.approx(1.0) + event = normalize_event(many) + assert event.id == "myriad:2741-amm-412" and event.mutually_exclusive is True and len(event.markets) == 4 + + @pytest.mark.parametrize("state, expected", [("open", "open"), ("closed", "closed"), ("resolved", "settled")]) + def test_statuses(self, state, expected): + assert status_of({"state": state})[0] == expected + + def test_a_resolved_market_names_its_winner(self): + market = normalize_market({**STANDALONE, "state": "resolved", "resolvedOutcomeId": 1}) + assert market.status == "settled" and market.info["winning_outcome"] == "no" and not market.active + + def test_amounts_come_in_wei_or_in_tokens(self): + assert units("1000000000000000000") == 1.0 and units("98.00000000") == 98.0 and units(None) is None + + +class TestEvents: + def test_a_venue_event_holds_its_markets_in_order(self): + event = normalize_event({**EVENT, "type": "event"}) + assert event.id == f"myriad:{EVENT['id']}" and [m.outcome_label for m in event.markets] == \ + [m["shortName"] for m in EVENT["markets"]] + assert event.mutually_exclusive is (True if EVENT["negRisk"] else None) + + def test_the_grouped_listing_mixes_events_and_markets(self): + events = [normalize_event(row) for row in GROUPED["data"]] + kinds = {row["type"] for row in GROUPED["data"]} + assert kinds == {"event", "market"} and all(e.markets for e in events) + for row, event in zip(GROUPED["data"], events): + if row["type"] == "market": + assert len(event.markets) == 1 and event.markets[0].event_id == event.id + + +class TestBooks: + def test_each_outcome_book_is_read_as_served(self): + yes = normalize_order_book(load("myriad_book_yes.json"), market_id="myriad:56-2385") + no = normalize_order_book(load("myriad_book_no.json"), market_id="myriad:56-2385", side="no") + assert yes.best_bid.price == 0.03 and yes.best_ask.price == 0.06 and not no.derived + assert yes.best_ask.price + no.best_bid.price == pytest.approx(1.0) + assert yes.best_ask.size == pytest.approx(no.best_bid.size), "the venue's books are each other's mirror" + assert yes.bids == sorted(yes.bids, key=lambda level: -level.price) + + def test_the_pool_book_matches_the_venues_own_quotes(self): + """The recorded quotes were taken with the pool in this state: buying + 25 USD1 of the first outcome, and selling 20 shares of it.""" + bids, asks = amm_book_levels([o["shares"] for o in AMM["outcomes"]], 0) + buy, sell = load("myriad_quote_buy.json"), load("myriad_quote_sell.json") + spent = shares = 0.0 + for level in asks: + cost = level.price * level.size + if spent + cost >= buy["value"]: + shares += (buy["value"] - spent) / level.price + break + spent, shares = spent + cost, shares + level.size + assert shares == pytest.approx(buy["shares"], rel=2e-3) + received = sold = 0.0 + for level in bids: + if sold + level.size >= sell["shares"]: + received += (sell["shares"] - sold) * level.price + break + received, sold = received + level.price * level.size, sold + level.size + assert received == pytest.approx(sell["value"], rel=2e-3) + + def test_the_pool_book_moves_a_cent_a_level(self): + book = amm_order_book(AMM, market_id="myriad:56-amm-120473", index=0) + mid = AMM["outcomes"][0]["price"] + assert book.info["depth_source"] == "amm" and book.best_bid.price < mid < book.best_ask.price + assert len(book.asks) == 99 - int(mid * 100) and len(book.bids) == int(mid * 100) + no = amm_order_book(AMM, market_id="myriad:56-amm-120473", index=0, side="no") + assert no.best_ask.price == pytest.approx(1 - book.best_bid.price, abs=0.011) and not no.derived + + def test_one_outcome_of_many_mirrors_its_yes_book_for_no(self): + many = AMM_MARKETS[2] | {"outcomes": AMM_MARKETS[1]["outcomes"]} + book = amm_order_book(many, market_id="myriad:2741-amm-317-1", index=1, side="no", native_no=False) + assert book.derived and book.bids and book.asks + + def test_the_adapter_reads_outcome_one_for_no(self): + venue = venue_with({"/markets/2385/orderbook": lambda p: load(f"myriad_book_{'yes' if p['outcome'] == 0 else 'no'}.json")}) + books = venue.fetch_order_books(["myriad:56-2385", "56-2385"], side="no") + assert list(books) == ["myriad:56-2385"] and books["myriad:56-2385"].side == "no" + assert venue.http.calls == [("/markets/2385/orderbook", {"network_id": 56, "outcome": 1})] + + +class TestTrades: + def test_one_trade_per_taker_leg(self): + trades = normalize_trades(TRADES, market_id="myriad:56-2385") + legs = [row for row in TRADES if row["side"] not in ("split", "merge")] + takers = [row for row in legs if float(row["fees"]["total"]) > 0] + assert len(trades) == len(takers) and all(t.side in ("buy", "sell") for t in trades) + assert trades == sorted(trades, key=lambda t: t.timestamp) + + def test_the_yes_price_and_the_takers_side(self): + trades = {t.info["txHash"][:10] + ":" + str(t.info["outcome"]) + t.info["side"]: t + for t in normalize_trades(TRADES, market_id="myriad:56-2385")} + sold_yes = trades["0xc3906b47:0sell"] + assert sold_yes.side == "sell" and sold_yes.price == 0.03 + bought_no = next(t for k, t in trades.items() if k.endswith(":1sell")) + assert bought_no.side == "buy", "selling NO is buying YES" + + def test_a_match_without_a_fee_is_reported_once_side_unknown(self): + rows = [{**row, "fees": {"total": "0"}} for row in TRADES if row["txHash"].startswith("0xc3906b47")] + trades = normalize_trades(rows, market_id="myriad:56-2385") + assert len(trades) == 1 and trades[0].side == "unknown" + + def test_pool_trades(self): + rows = load("myriad_amm_events.json")["data"] + trades = [t for t in (normalize_amm_trade(r, market_id="myriad:56-amm-120473", index=0, many=False) for r in rows) if t] + assert trades and all(0 < t.price < 1 for t in trades) + first = next(t for t in trades if t.info["outcomeId"] == 0 and t.info["action"] == "buy") + assert first.side == "buy" and first.price == pytest.approx(first.info["value"] / first.info["shares"], abs=1e-6) + + def test_the_adapter_pages_the_order_book_tape(self): + venue = venue_with({"/markets/2385/trades": TRADES}) + page = venue.fetch_trades("myriad:56-2385", limit=20) + assert page.next_cursor == "2" and venue.http.calls[0][1]["page"] == 1 + + +class TestCandlesAndFees: + def test_the_chart_covers_the_span(self): + assert chart_for(3600) == "24h" and chart_for(3 * 86400) == "7d" and chart_for(90 * 86400) == "all" + + def test_samples_become_bars(self): + points = MARKET["outcomes"][0]["price_charts"][0]["prices"] + candles = candles_from_chart(points, interval_seconds=3600) + assert candles and all(c.volume is None and c.price_source == "sampled_mid" for c in candles) + assert sum(c.info["samples"] for c in candles) == len(points) + + def test_the_order_book_fee_follows_the_venues_table(self): + fee = fee_schedule_of(MARKET, market_id="56-3722") + table = MARKET["fees"]["taker_fee_bps_array"] + assert fee.fee_type == "bps_by_price" and fee.taker_bps_by_cent == table and fee.maker_rate == 0 + assert fee.estimate(0.5, 100) == pytest.approx(table[49] / 10_000 * 50) + assert fee.estimate(0.05, 12.03007519) == pytest.approx(0.00108271, abs=1e-6), "a recorded taker fill's fee" + assert fee.estimate(0.5, 100, taker=False) == 0 + + def test_an_amm_fee_is_a_share_of_the_value(self): + fee = fee_schedule_of({**AMM, "fees": {"buy": {"fee": 0.01, "treasury_fee": 0.01, "distributor_fee": 0}, + "sell": {"fee": 0, "treasury_fee": 0, "distributor_fee": 0}}}, + market_id="56-amm-120473") + assert fee.fee_type == "share_of_value" and fee.taker_rate == 0.02 and fee.estimate(0.5, 10) == 0.1 + + +class TestAdapter: + def test_every_read_asks_for_both_kinds_of_market_and_drops_points(self): + seen = {} + + def listing(params): + seen.update(params) + return {"data": AMM_MARKETS, "pagination": {"hasNext": False}} + + markets = venue_with({"/markets": listing}).fetch_markets(topic="sports", tag="nfl", in_play=True) + assert seen["trading_model"] == "all" and seen["network_id"] == "56,2741,59144" + assert seen["topics"] == "Sports" and seen["tags"] == "nfl" and seen["in_play"] == "true" + assert [m.id for m in markets] == ["myriad:2741-amm-317"], "points markets are not money" + + def test_sort_and_status_reach_the_venue(self): + seen = {} + venue = venue_with({"/markets": lambda p: seen.update(p) or {"data": [], "pagination": {}}}) + venue.fetch_markets(sort="newest", status="settled") + assert seen["sort"] == "published_at" and seen["state"] == "resolved" + venue.fetch_markets(status="all") + assert seen["state"] is None + + def test_paging_counts_markets_not_rows(self): + pages = {1: {"data": MARKETS, "pagination": {"hasNext": True}}, 2: {"data": [AMM], "pagination": {"hasNext": False}}} + venue = venue_with({"/markets": lambda p: pages[p["page"]]}) + first = venue.fetch_markets(limit=2) + assert len(first) == 2 and first.next_cursor == "1:2" + rest = venue.fetch_markets(limit=10, cursor=first.next_cursor) + assert [m.id for m in rest] == ["myriad:56-3785", "myriad:56-amm-120473"] and rest.next_cursor is None + + def test_one_market_by_id_or_slug(self): + venue = venue_with({"/markets/3722": MARKET, "/markets/btc-slug": MARKET}) + assert venue.fetch_market("myriad:56-3722").id == "myriad:56-3722" + assert venue.http.calls[-1][1] == {"network_id": 56, "trading_model": "ob"} + assert venue.fetch_market("btc-slug").id == "myriad:56-3722" + with pytest.raises(MarketNotFound): + venue.fetch_market("myriad:56-amm-3722"), "the AMM market with that number is another market" + + def test_batch_lookup_groups_by_network_and_kind(self): + def by_ids(params): + rows = MARKETS if params["trading_model"] == "ob" else [AMM] + wanted = set(params["ids"].split(",")) + return {"data": [r for r in rows if str(r["id"]) in wanted]} + + venue = venue_with({"/markets": by_ids}) + found = venue.fetch_markets_by_ids(["myriad:56-amm-120473", "myriad:56-3785", "myriad:56-999", "56-2385"]) + assert [m.id for m in found] == ["myriad:56-amm-120473", "myriad:56-3785", "myriad:56-2385"] + assert len(venue.http.calls) == 2 + + def test_events_read_the_grouped_listing(self): + seen = {} + venue = venue_with({"/markets": lambda p: seen.update(p) or GROUPED}) + events = venue.fetch_events(limit=10) + assert seen["group_by_event"] == "true" and len(events) == len(GROUPED["data"]) + assert events.next_cursor == "2:10" + venue.fetch_events(cursor=events.next_cursor) + assert seen["page"] == 2 and seen["limit"] == 10, "the next page is read at the size the cursor came from" + + def test_a_key_signs_every_read(self): + sent = [] + + def handler(request): + sent.append(request) + return httpx.Response(200, json={"data": [], "pagination": {}}) + + venue = Myriad(api_key="key-1", api_secret="secret-1", limiter=None) + venue.http._client = httpx.Client(transport=httpx.MockTransport(handler), + auth=venue.http._client.auth) + venue.fetch_markets(query="btc") + request = sent[0] + path = request.url.raw_path.decode() + assert request.headers["x-api-key"] == "key-1" + assert request.headers["x-api-signature"] == request_signature( + "secret-1", request.headers["x-api-timestamp"], "GET", path, "") + + def test_an_unknown_market_is_not_found(self): + venue = Myriad(limiter=None) + venue.http._client = httpx.Client(transport=httpx.MockTransport( + lambda request: httpx.Response(404, json=load("myriad_not_found.json")))) + with pytest.raises(MarketNotFound, match="56-999999"): + venue.fetch_market("myriad:56-999999") + + def test_a_key_without_its_secret_is_refused(self): + with pytest.raises(BadRequest, match="secret"): + Myriad(api_key="key-only") diff --git a/tests/test_myriad_live.py b/tests/test_myriad_live.py new file mode 100644 index 0000000..6cc4de2 --- /dev/null +++ b/tests/test_myriad_live.py @@ -0,0 +1,52 @@ +"""Myriad against the real venue: run with `pytest -m live`. No key needed.""" +from __future__ import annotations + +import pytest + +from synpath import Myriad + +pytestmark = pytest.mark.live + + +@pytest.fixture(scope="module") +def venue(): + with Myriad() as myriad: + yield myriad + + +@pytest.fixture(scope="module") +def markets(venue): + return venue.fetch_markets(limit=40, sort="volume") + + +def test_the_catalog_holds_both_kinds_and_no_points(markets): + assert markets and all(m.status == "open" for m in markets) + assert {m.info["trading_model"] for m in markets} <= {"ob", "amm"} + assert not any(m.info["points"] for m in markets) + + +def test_paging_neither_repeats_nor_drops(venue, markets): + following = venue.fetch_markets(limit=40, sort="volume", cursor=markets.next_cursor) + assert not {m.id for m in markets} & {m.id for m in following} + + +def test_an_order_book_market(venue): + market = next(iter(venue.fetch_markets(limit=40, trading_model="ob"))) + yes, no = venue.fetch_order_book(market.id), venue.fetch_order_book(market.id, side="no") + if yes.best_ask and no.best_bid: + assert yes.best_ask.price + no.best_bid.price == pytest.approx(1.0) + assert venue.fetch_trades(market.id, limit=20) is not None + assert venue.fetch_fee_schedule(market.id).fee_type == "bps_by_price" + assert all(c.price_source == "sampled_mid" for c in venue.fetch_ohlcv(market.id, limit=12)) + + +def test_an_amm_market_book_brackets_its_price(venue): + market = next(iter(venue.fetch_markets(limit=40, trading_model="amm"))) + book = venue.fetch_order_book(market.id) + assert book.info["depth_source"] == "amm" and book.best_bid.price <= market.yes.quote.mid <= book.best_ask.price + + +def test_sports_and_events(venue): + assert all("sports" in m.tags for m in venue.fetch_markets(limit=10, topic="sports")) + events = venue.fetch_events(limit=10) + assert events and all(e.markets for e in events) diff --git a/tests/test_myriad_trading.py b/tests/test_myriad_trading.py new file mode 100644 index 0000000..57c6c10 --- /dev/null +++ b/tests/test_myriad_trading.py @@ -0,0 +1,550 @@ +"""Myriad order entry: signing checked against an independent EIP-712 encoder +and fixed known answers, request signing against the reference's HMAC recipe, +order bodies, REST records, and the adapter against a scripted venue.""" +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +from decimal import Decimal + +import httpx +import pytest +from eth_account import Account as EthAccount +from eth_account.messages import _hash_eip191_message, encode_defunct, encode_typed_data + +from synpath.myriad import Ref, fee_schedule_of +from synpath.trading import myriad_signing as sig +from synpath.trading.credentials import CredentialsMissing, MyriadCredentials, load_myriad +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderRejected +from synpath.trading.myriad import ( + MyriadTrading, build_signed_order, fill_of_action, fills_of_trade, order_of_record, order_of_update, + positions_of, time_in_force_of, +) +from synpath.trading.polymarket_signing import WalletSigner +from synpath.trading.types import ( + EditRequest, Liquidity, OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce, +) + +from conftest import load + +KEY = "0x" + "11" * 32 +OWNER = EthAccount.from_key(KEY) +MARKET = load("myriad_market.json") +MARKET_ID = "myriad:56-3722" + + +def request(**changes) -> OrderRequest: + base = {"market_id": MARKET_ID, "side": Side.BUY, "quantity": Decimal("10"), "price": Decimal("0.4")} + return OrderRequest(**{**base, **changes}) + + +def signed(req: OrderRequest): + return build_signed_order(req, signer=WalletSigner(KEY), market=3722, nonce=7) + + +def recover(entry: dict) -> str: + order = sig.order_of_wire(entry["order"]) + return EthAccount.recover_message(encode_typed_data(full_message=sig.order_typed_data(order)), signature=entry["signature"]) + + +class TestSigning: + ORDER = sig.build_order(trader=OWNER.address, market_id=2385, outcome=0, side=0, amount=10**19, + price=5 * 10**16, nonce=12345, expiration=0) + + def test_the_digest_is_eip712(self): + encoded = encode_typed_data(full_message=sig.order_typed_data(self.ORDER)) + assert sig.order_digest(self.ORDER) == _hash_eip191_message(encoded) + + def test_known_answers(self): + """Fixed key, fixed order: the hash and signature may never drift.""" + assert OWNER.address == "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A" + assert sig.order_hash(self.ORDER) == "0xa7a7d48560e9e757da4406970807265acfc4b087246a02c40ab5f23c37739a32" + signature, digest = sig.sign_order(WalletSigner(KEY), self.ORDER) + assert digest == sig.order_hash(self.ORDER) + assert signature == ("0x7291634179ef86834be486a8972f329a31e3b86590a5f7b7c31667f7e3256ee97c13b535a3f0d41c9ed32" + "b6b077d900671647223ab277d133338b4805c9dea3d1c") + assert sig.cancel_all_digest(OWNER.address, 0, 1792772000).hex() == \ + "b51cd34b14c66ed047fa4beebaebd18818a78f31b968019c6e04da476fbf7b74" + + def test_the_signature_is_the_wallets(self): + signature, _ = sig.sign_order(WalletSigner(KEY), self.ORDER) + encoded = encode_typed_data(full_message=sig.order_typed_data(self.ORDER)) + assert signature == "0x" + OWNER.sign_message(encoded).signature.hex().removeprefix("0x") + + def test_cancel_all_is_signed_typed_data(self): + body = sig.sign_cancel_all(WalletSigner(KEY), 42, timestamp=1792772000) + typed = sig.cancel_all_typed_data(OWNER.address, 42, 1792772000) + assert EthAccount.recover_message(encode_typed_data(full_message=typed), signature=body["signature"]) == OWNER.address + assert body["market_id"] == 42 and body["timestamp"] == "1792772000" and body["network_id"] == 56 + assert "market_id" not in sig.sign_cancel_all(WalletSigner(KEY), timestamp=1), "0 is every market" + + def test_the_sign_in_is_a_personal_message(self): + message = "myriad.markets wants you to sign in with your Ethereum account" + assert EthAccount.recover_message(encode_defunct(text=message), + signature=sig.sign_login(WalletSigner(KEY), message)) == OWNER.address + + def test_requests_are_signed_as_the_reference_says(self): + body = '{"a":1}' + headers = sig.request_headers("key-1", "secret-1", "post", "/orders?x=1", body, now=1792772000) + expected = hmac.new(b"secret-1", b'1792772000.POST./orders?x=1.{"a":1}', hashlib.sha256).hexdigest() + assert headers == {"x-api-key": "key-1", "x-api-timestamp": "1792772000", "x-api-signature": expected} + assert sig.request_signature("s", "1", "GET", "/orders") == hmac.new(b"s", b"1.GET./orders.", hashlib.sha256).hexdigest() + + def test_prices_are_whole_cents(self): + assert sig.price_wei(Decimal("0.57")) == 570000000000000000 + with pytest.raises(InvalidOrder, match="cent"): + sig.price_wei(Decimal("0.575")) + with pytest.raises(InvalidOrder): + sig.price_wei(Decimal("1")) + + def test_approvals_name_the_exchange(self): + calls = sig.approval_calls() + assert [c["to"] for c in calls] == [sig.USD1, sig.CONDITIONAL_TOKENS] + assert calls[0]["data"].startswith("0x095ea7b3") and sig.EXCHANGE[2:].lower() in calls[0]["data"] + assert calls[1]["data"].startswith("0xa22cb465") + + +class TestOrderBody: + def test_a_buy_buys_outcome_zero(self): + entry, digest, contracts = signed(request()) + order = entry["order"] + assert (order["outcomeId"], order["side"], order["price"], order["amount"]) == (0, 0, "400000000000000000", + "10000000000000000000") + assert order["marketId"] == "3722" and order["expiration"] == "0" and order["nonce"] == "7" + assert entry["time_in_force"] == "GTC" and contracts == Decimal("10") and recover(entry) == OWNER.address + assert digest == sig.order_hash(sig.order_of_wire(order)) + + def test_a_sell_buys_outcome_one_at_the_complement(self): + order = signed(request(side=Side.SELL, price=Decimal("0.3")))[0]["order"] + assert (order["outcomeId"], order["side"], order["price"]) == (1, 0, "700000000000000000") + + def test_reduce_only_sells_what_is_held(self): + assert [signed(request(side=Side.SELL, reduce_only=True))[0]["order"][k] for k in ("outcomeId", "side")] == [0, 1] + assert [signed(request(side=Side.BUY, reduce_only=True))[0]["order"][k] for k in ("outcomeId", "side")] == [1, 1] + + @pytest.mark.parametrize("changes, tif, expiration", [ + ({}, "GTC", 0), + ({"time_in_force": TimeInForce.GTD, "expires_at": 1_900_000_000_000}, "GTD", 1_900_000_000), + ({"time_in_force": TimeInForce.IOC}, "FAK", 0), + ({"time_in_force": TimeInForce.FOK}, "FOK", 0), + ({"type": OrderType.MARKET, "time_in_force": TimeInForce.IOC}, "FAK", 0), + ({"post_only": True}, "PO", 0), + ({"post_only": True, "time_in_force": TimeInForce.GTD, "expires_at": 1_900_000_000_000}, "PO", 1_900_000_000), + ]) + def test_time_in_force(self, changes, tif, expiration): + assert time_in_force_of(request(**changes)) == (tif, expiration) + + @pytest.mark.parametrize("changes, match", [ + ({"post_only": True, "time_in_force": TimeInForce.IOC}, "post-only"), + ({"time_in_force": TimeInForce.DAY}, "day"), + ({"type": OrderType.ICEBERG}, "engine"), + ({"price": Decimal("0.405")}, "cent"), + ]) + def test_refusals(self, changes, match): + with pytest.raises(InvalidOrder, match=match): + signed(request(**changes)) + + +class TestRecords: + RECORD = { + "orderHash": "0xabc", "clientOrderId": "1ccd-17256719159", + "order": {"trader": OWNER.address, "marketId": 42, "outcomeId": 1, "side": 0, "amount": "10000000000000000000", + "price": "300000000000000000", "minFillAmount": "0", "nonce": "1", "expiration": "0"}, + "status": "open", "signatureType": 0, "filledAmount": "4000000000000000000", "timeInForce": "GTC", + "networkId": 56, "createdAt": "2025-07-01T12:00:00.000Z", + } + + def test_an_order_on_outcome_one_reads_on_yes(self): + order = order_of_record(self.RECORD) + assert (order.side, order.price, order.quantity, order.filled, order.remaining) == ( + Side.SELL, Decimal("0.7"), Decimal("10"), Decimal("4"), Decimal("6")) + assert order.market_id == "myriad:56-42" and order.status == OrderStatus.OPEN and not order.reduce_only + assert order.client_order_id == "1ccd-17256719159" and order.created_at == 1751371200000 + assert order_of_record({**self.RECORD, "status": "filled"}).status == OrderStatus.CLOSED + assert order_of_record({**self.RECORD, "timeInForce": "PO"}).post_only + + def test_an_order_update_from_the_stream(self): + update = {"type": "PARTIALLY_FILLED", "orderHash": "0x1", "networkId": 56, "marketId": 42, "side": "buy", + "outcome": 0, "price": "500000000000000000", "amount": "1000000000000000000", + "filledAmount": "400000000000000000", "status": "open", "timeInForce": "GTC"} + order = order_of_update(update) + assert (order.side, order.price, order.filled, order.remaining) == (Side.BUY, Decimal("0.5"), Decimal("0.4"), + Decimal("0.6")) + assert order_of_update({**update, "type": "FILLED", "status": "filled"}).status == OrderStatus.CLOSED + + def test_this_accounts_legs_of_a_match_are_its_fills(self): + trade = {"marketId": 42, "networkId": 56, "txHash": "0xt", "ts": 1719835200123, + "taker": {"trader": "0xTAKER", "outcome": 0, "side": "buy", "orderHash": "0xo1", + "totalAmount": "1000000000000000000", "averagePrice": "500000000000000000", + "totalFees": {"total": "10000000000000000"}}, + "makers": [{"trader": OWNER.address, "role": "maker", "outcome": 0, "side": "sell", "orderHash": "0xo2", + "price": "500000000000000000", "amount": "1000000000000000000", "fees": {"total": "0"}}]} + [fill] = fills_of_trade(trade, address=OWNER.address) + assert (fill.side, fill.price, fill.quantity, fill.liquidity, fill.fee) == ( + Side.SELL, Decimal("0.5"), Decimal("1"), Liquidity.MAKER, Decimal("0")) + assert fill.id == "0xt:0:sell" and fill.order_id == "0xo2" and fill.market_id == "myriad:56-42" + [taker] = fills_of_trade(trade, address="0xtaker") + assert taker.liquidity == Liquidity.TAKER and taker.fee == Decimal("0.01") + + def test_history_rows_are_fills_without_an_order(self): + row = {"user": OWNER.address, "action": "buy", "marketId": 42, "networkId": 56, "outcomeId": 1, + "shares": 10, "value": 3, "timestamp": 1719835200, "txId": "0xt"} + fill = fill_of_action(row) + assert (fill.side, fill.price, fill.quantity, fill.order_id) == (Side.SELL, Decimal("0.7"), Decimal("10"), "") + assert fill.id == "0xt:1:buy" and fill_of_action({**row, "action": "claim_winnings"}) is None + + def test_positions_net_on_yes(self): + rows = [ + {"marketId": 42, "networkId": 56, "executionMode": 1, "outcomeId": 0, "shares": 10, "price": 0.4, + "value": 5, "profit": 1, "status": "ongoing"}, + {"marketId": 42, "networkId": 56, "executionMode": 1, "outcomeId": 1, "shares": 4, "price": 0.5, + "value": 2, "profit": 0, "status": "ongoing"}, + {"marketId": 412, "networkId": 2741, "executionMode": 0, "outcomeId": 2, "shares": 3, "price": 0.1, + "value": 0.6, "profit": 0.3, "status": "won", "winningsToClaim": True}, + ] + binary, outcome = positions_of(rows) + assert binary.market_id == "myriad:56-42" and binary.side == PositionSide.LONG and binary.contracts == 6 + assert (binary.inventory_yes, binary.inventory_no, binary.entry_price, binary.mark_price) == ( + Decimal("10"), Decimal("4"), Decimal("0.4"), Decimal("0.5")) + assert outcome.market_id == "myriad:2741-amm-412-2" and outcome.inventory_no is None + assert outcome.resolved and outcome.won and outcome.redeemable == Decimal("3") + + +class TestCredentials: + def test_a_bare_key_is_refused(self): + with pytest.raises(CredentialsMissing, match="SECRET"): + load_myriad({"MYRIAD_API_KEY": "k"}) + with pytest.raises(CredentialsMissing): + load_myriad({"MYRIAD_API_SECRET": "s"}) + + def test_a_private_key_alone_signs_in(self): + creds = load_myriad({"MYRIAD_PRIVATE_KEY": KEY[2:]}) + assert creds.private_key == KEY and creds.api_key is None and creds.address == OWNER.address + assert KEY not in repr(creds) and creds.secrets == [KEY] + + def test_a_key_alone_reads_for_the_address_given(self): + creds = load_myriad({"MYRIAD_API_KEY": "k", "MYRIAD_API_SECRET": "the-secret", "MYRIAD_ACCOUNT_ADDRESS": OWNER.address}) + assert creds.private_key is None and creds.address == OWNER.address and "the-secret" not in repr(creds) + with pytest.raises(CredentialsMissing, match="not the address"): + load_myriad({"MYRIAD_PRIVATE_KEY": KEY, "MYRIAD_ACCOUNT_ADDRESS": "0x" + "22" * 20}) + with pytest.raises(CredentialsMissing, match="private key"): + load_myriad({"MYRIAD_API_KEY": "k", "MYRIAD_API_SECRET": "s", "MYRIAD_ACCOUNT_ADDRESS": KEY}) + assert load_myriad({}) is None + + +# --------------------------------------------------------------------------- +# The adapter against a scripted venue +# --------------------------------------------------------------------------- + +class Catalog: + def _resolve(self, market_id): + return Ref(56, 3722), MARKET + + def fetch_fee_schedule(self, market_id): + return fee_schedule_of(MARKET, market_id="56-3722") + + def close(self): + pass + + +class Venue: + """Answers the venue's routes, checks every keyed request's signature, and records them.""" + + def __init__(self, keyed=True, **answers): + self.keyed = keyed + self.answers = answers + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + if self.keyed: + target = req.url.raw_path.decode() + expected = sig.request_signature("secret-1", req.headers["x-api-timestamp"], req.method, target, + req.content.decode()) + assert req.headers["x-api-signature"] == expected and req.headers["x-api-key"] == "key-1" + answer = self.answers.get(f"{req.method} {req.url.path}") + if answer is None: + return httpx.Response(404, json={"error": "Order not found"}) + return answer(req) if callable(answer) else httpx.Response(200, json=answer) + + def sent(self, method: str, path: str) -> list[httpx.Request]: + return [r for r in self.requests if r.method == method and r.url.path == path] + + +def adapter(venue: Venue, *, keyed=True, rpc=None) -> MyriadTrading: + creds = MyriadCredentials(private_key=KEY, api_key="key-1" if keyed else None, api_secret="secret-1" if keyed else None) + return MyriadTrading( + creds, catalog=Catalog(), client=httpx.AsyncClient(transport=httpx.MockTransport(venue)), + rpc_client=rpc or httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500))), + ) + + +def run(coro): + return asyncio.run(coro) + + +def echo_order(status="open"): + def answer(req): + body = json.loads(req.content) + digest = sig.order_hash(sig.order_of_wire(body["order"])) + return httpx.Response(200, json={"orderHash": digest, "status": status, "timeInForce": body["time_in_force"]}) + return answer + + +def record_of(entry: dict, status="open", filled="0") -> dict: + digest = sig.order_hash(sig.order_of_wire(entry["order"])) + return {"orderHash": digest, "order": entry["order"], "status": status, "filledAmount": filled, + "timeInForce": entry.get("time_in_force", "GTC"), "networkId": 56} + + +class TestAdapter: + def test_an_order_is_signed_and_sent_compact(self): + venue = Venue(**{"POST /orders": echo_order()}) + + async def go(): + async with adapter(venue) as myriad: + return await myriad.create_order(request(client_order_id="c-1")) + + order = run(go()) + [sent] = venue.sent("POST", "/orders") + body = json.loads(sent.content) + assert b": " not in sent.content and body["network_id"] == 56 and body["client_order_id"] == "c-1" + assert recover(body) == OWNER.address and body["signatureType"] == 0 + assert order.id == sig.order_hash(sig.order_of_wire(body["order"])) and order.status == OrderStatus.OPEN + assert order.market_id == MARKET_ID and order.quantity == Decimal("10") and order.price == Decimal("0.4") + assert body["signature"] not in json.dumps(order.info) + + def test_a_cancel_repeats_the_order_it_placed(self): + orders: dict[str, dict] = {} + + def place(req): + body = json.loads(req.content) + orders[sig.order_hash(sig.order_of_wire(body["order"]))] = body + return echo_order()(req) + + def cancel(req): + return httpx.Response(200, json={"orderHash": req.url.path.rsplit("/", 1)[1], "status": "cancelled"}) + + def read(req): + digest = req.url.path.rsplit("/", 1)[1] + return httpx.Response(200, json=record_of(orders[digest], status="cancelled")) + + venue = Venue(**{"POST /orders": place}) + + async def go(): + async with adapter(venue) as myriad: + order = await myriad.create_order(request()) + venue.answers[f"DELETE /orders/{order.id}"] = cancel + venue.answers[f"GET /orders/{order.id}"] = read + return order, await myriad.cancel_order(order.id) + + placed, cancelled = run(go()) + [delete] = venue.sent("DELETE", f"/orders/{placed.id}") + body = json.loads(delete.content) + assert body["signature"] == orders[placed.id]["signature"] and body["order"] == orders[placed.id]["order"] + assert cancelled.status == OrderStatus.CANCELED + + def test_an_order_placed_elsewhere_is_read_back_and_signed_again(self): + entry, digest, _ = signed(request()) + venue = Venue(**{ + f"GET /orders/{digest}": record_of(entry), + f"DELETE /orders/{digest}": {"orderHash": digest, "status": "cancelled"}, + }) + + async def go(): + async with adapter(venue) as myriad: + await myriad.cancel_order(digest) + + run(go()) + body = json.loads(venue.sent("DELETE", f"/orders/{digest}")[0].content) + assert body["signature"] == entry["signature"], "the same order and key sign the same signature" + + def test_cancel_all_signs_cancel_all(self): + venue = Venue(**{"POST /orders/cancel-all": {"cancelled_count": 3, "market_ids_affected": []}}) + + async def go(): + async with adapter(venue) as myriad: + return await myriad.cancel_all_orders(market_id=MARKET_ID), await myriad.cancel_all_orders() + + assert run(go()) == (3, 3) + first, every = (json.loads(r.content) for r in venue.sent("POST", "/orders/cancel-all")) + assert first["market_id"] == 3722 and "market_id" not in every and first["trader"] == OWNER.address + + def test_a_batch_reports_each_order(self): + def batch(req): + body = json.loads(req.content) + hashes = [sig.order_hash(sig.order_of_wire(e["order"])) for e in body["orders"]] + return httpx.Response(200, json={"placed": [hashes[0]], "errors": [ + {"index": 1, "orderHash": None, "reason": "Invalid order signature"}]}) + + venue = Venue(**{"POST /orders/batch": batch}) + + async def go(): + async with adapter(venue) as myriad: + return await myriad.create_orders([request(), request(price=Decimal("0.3")), + request(price=Decimal("0.305"))]) + + ok, refused, invalid = run(go()) + assert ok.status == OrderStatus.OPEN and isinstance(refused, InvalidOrder) and isinstance(invalid, InvalidOrder) + [sent] = venue.sent("POST", "/orders/batch") + assert len(json.loads(sent.content)["orders"]) == 2, "an off-tick order never reaches the venue" + + def test_an_edit_is_one_atomic_cancel_and_place(self): + entry, digest, _ = signed(request()) + current = order_of_record(record_of(entry)) + + def modify(req): + body = json.loads(req.content) + new = sig.order_hash(sig.order_of_wire(body["place"][0]["order"])) + return httpx.Response(200, json={"placed": [new], "cancelled": [digest], "errors": []}) + + venue = Venue(**{f"GET /orders/{digest}": record_of(entry), "POST /orders/batch-modify": modify}) + + async def go(): + async with adapter(venue) as myriad: + return await myriad.edit_order(EditRequest(order_id=digest, price=Decimal("0.45")), current=current) + + edited = run(go()) + body = json.loads(venue.sent("POST", "/orders/batch-modify")[0].content) + assert body["cancel"][0]["signature"] == entry["signature"] and body["allow_partial"] is False + assert body["place"][0]["order"]["price"] == "450000000000000000" + assert edited.queue_priority_preserved is False and edited.price == Decimal("0.45") + + def test_refusals_are_typed(self): + venue = Venue(**{"POST /orders": lambda r: httpx.Response(400, json={"error": "Insufficient balance or allowance"})}) + + async def go(): + async with adapter(venue) as myriad: + return await myriad.create_order(request()) + + with pytest.raises(InsufficientFunds): + run(go()) + venue.answers["POST /orders"] = lambda r: httpx.Response(409, json={"error": "Order already exists"}) + with pytest.raises(OrderRejected): + run(go()) + + def test_an_amm_market_is_refused(self): + async def go(): + async with adapter(Venue()) as myriad: + return await myriad.create_order(request(market_id="myriad:56-amm-120473")) + + with pytest.raises(InvalidOrder, match="AMM"): + run(go()) + + def test_without_a_key_the_wallet_signs_in(self): + calls = [] + + def nonce(req): + calls.append("nonce") + assert req.url.params["address"] == OWNER.address + return httpx.Response(200, json={"nonce": "n-1", "message": "Sign in to Myriad\nNonce: n-1"}) + + def login(req): + calls.append("login") + body = json.loads(req.content) + assert EthAccount.recover_message(encode_defunct(text=body["message"]), signature=body["signature"]) == OWNER.address + return httpx.Response(200, json={"accessToken": "jwt-1", "refreshToken": "r-1"}) + + def orders(req): + calls.append(req.headers.get("authorization")) + return httpx.Response(200, json={"data": [], "pagination": {"hasNext": False}}) + + venue = Venue(keyed=False, **{"GET /auth/nonce": nonce, "POST /auth/login": login, "GET /orders": orders}) + + async def go(): + async with adapter(venue, keyed=False) as myriad: + await myriad.fetch_open_orders() + await myriad.fetch_open_orders() + return await myriad.create_order(request(client_order_id="c-1")) + + venue.answers["POST /orders"] = echo_order() + run(go()) + assert calls == ["nonce", "login", "Bearer jwt-1", "Bearer jwt-1"] + body = json.loads(venue.sent("POST", "/orders")[0].content) + assert "client_order_id" not in body, "the venue takes a client order id only with an API key" + assert venue.sent("POST", "/orders")[0].headers["authorization"] == "Bearer jwt-1" + + def test_open_orders_positions_and_fills(self): + entry, digest, _ = signed(request(side=Side.SELL, price=Decimal("0.3"))) + venue = Venue(**{ + "GET /orders": {"data": [record_of(entry)], "pagination": {"hasNext": False}}, + f"GET /users/{OWNER.address}/portfolio": {"data": [ + {"marketId": 3722, "networkId": 56, "executionMode": 1, "outcomeId": 0, "shares": 2, "price": 0.9, + "value": 1.8, "profit": 0, "status": "ongoing"}], "pagination": {"hasNext": False}}, + f"GET /users/{OWNER.address}/events": {"data": [ + {"action": "buy", "marketId": 3722, "networkId": 56, "outcomeId": 0, "shares": 2, "value": 1.8, + "timestamp": 1792772000, "txId": "0xt"}], "pagination": {"hasNext": False}}, + }) + + async def go(): + async with adapter(venue) as myriad: + return (await myriad.fetch_open_orders(market_id=MARKET_ID), await myriad.fetch_positions(), + await myriad.fetch_my_trades(since=1792771000000)) + + [order], [position], fills = run(go()) + assert order.id == digest and order.side == Side.SELL and order.price == Decimal("0.3") + assert venue.sent("GET", "/orders")[0].url.params["market_id"] == "3722" + assert position.market_id == MARKET_ID and position.contracts == Decimal("2") + assert [f.id for f in fills] == ["0xt:0:buy"] + assert venue.sent("GET", f"/users/{OWNER.address}/events")[0].url.params["since"] == "1792771000" + + def test_the_balance_is_the_wallets_usd1_less_open_buys(self): + entry, _, _ = signed(request(price=Decimal("0.4"))) + venue = Venue(**{"GET /orders": {"data": [record_of(entry)], "pagination": {}}}) + + def rpc(req): + call = json.loads(req.content)["params"][0] + assert call["to"] == sig.USD1 and OWNER.address[2:].lower() in call["data"] + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": hex(25 * 10**18)}) + + async def go(): + async with adapter(venue, rpc=httpx.AsyncClient(transport=httpx.MockTransport(rpc))) as myriad: + return await myriad.fetch_balance() + + balance = run(go()) + assert (balance.total, balance.locked, balance.available) == (Decimal("25"), Decimal("4"), Decimal("21")) + + def test_the_fee_estimate_reads_the_outcome_bought(self): + async def go(): + async with adapter(Venue()) as myriad: + return await myriad.fetch_fee_estimate(MARKET_ID, Side.SELL, Decimal("0.7"), Decimal("100")) + + estimate = run(go()) + table = MARKET["fees"]["taker_fee_bps_array"] + assert estimate.taker_fee == pytest.approx(Decimal(str(table[29] / 10_000 * 30))) + assert estimate.maker_fee == 0 + + def test_a_subscription_token_for_the_account_channels(self): + venue = Venue(**{"POST /auth/centrifugo/subscription-token": lambda r: httpx.Response( + 200, json={"token": "t-" + json.loads(r.content)["channel"], "expiresIn": 300})}) + + async def go(): + async with adapter(venue) as myriad: + return await myriad.subscription_token(f"orders:56:{OWNER.address.lower()}") + + assert run(go()) == f"t-orders:56:{OWNER.address.lower()}" + + +class TestWiring: + def test_the_client_and_the_engine_feeds_build_myriad(self): + from synpath import Client + from synpath.engine.feeds import default_streams + + creds = MyriadCredentials(private_key=KEY, api_key="key-1", api_secret="secret-1") + trading = Client({"myriad": creds}).trading("myriad") + assert isinstance(trading, MyriadTrading) + streams = default_streams({"myriad": trading}, {"myriad": creds})["myriad"] + assert streams.market.has["watch_order_book"] and streams.private.address == OWNER.address.lower() + run(trading.close()) + + def test_doctor_says_what_each_credential_does(self): + from synpath.trading.__main__ import myriad_identity + + both = myriad_identity(MyriadCredentials(private_key=KEY, api_key="key-12345678", api_secret="s")) + assert OWNER.address in both and "HMAC" in both and "places and cancels" in both + wallet = myriad_identity(MyriadCredentials(private_key=KEY)) + assert "signs in" in wallet + reader = myriad_identity(MyriadCredentials(api_key="k", api_secret="s", account_address=OWNER.address)) + assert "reads the account only" in reader and "s" * 3 not in reader diff --git a/tests/test_myriad_trading_live.py b/tests/test_myriad_trading_live.py new file mode 100644 index 0000000..4e90a7b --- /dev/null +++ b/tests/test_myriad_trading_live.py @@ -0,0 +1,30 @@ +"""Myriad order entry against the real venue, read-only: run with `pytest -m live`. +Needs MYRIAD_API_KEY and MYRIAD_API_SECRET with MYRIAD_PRIVATE_KEY or +MYRIAD_ACCOUNT_ADDRESS; skips without them. It places no order and does not sign in.""" +from __future__ import annotations + +import asyncio + +import pytest + +from synpath.trading.credentials import load_credentials +from synpath.trading.myriad import MyriadTrading + +CREDS = load_credentials(redact_logs=True).get("myriad") + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(CREDS is None or not CREDS.api_key or not CREDS.address, + reason="Myriad API key and wallet are not set"), +] + + +def test_the_account_reads_back(): + async def go(): + async with MyriadTrading(CREDS) as myriad: # type: ignore[arg-type] + return await myriad.fetch_balance(), await myriad.fetch_open_orders(), await myriad.fetch_positions() + + balance, orders, positions = asyncio.run(go()) + assert balance.total >= balance.available >= 0 + assert all(o.status.value == "open" for o in orders) + assert all(p.venue == "myriad" for p in positions) diff --git a/tests/test_myriad_ws.py b/tests/test_myriad_ws.py new file mode 100644 index 0000000..7d1b87f --- /dev/null +++ b/tests/test_myriad_ws.py @@ -0,0 +1,287 @@ +"""Myriad's streams: Centrifugo's protocol, spoken against a fake socket. +Protocol and book frames are recorded from the live venue; the trade, order +and position bodies follow the venue's API reference.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +import pytest + +from synpath import BadRequest, MyriadMarketStream, MyriadUserStream +from synpath.trading.types import OrderStatus, Side +from synpath.ws.base import BookEvent, FillEvent, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, TradeEvent +from synpath.ws.myriad import book_levels, status_event, trade_event + +from conftest import load + +FRAMES = load("myriad_ws.json") +PUSHES = [json.loads(f) for f in FRAMES if f.startswith('{"push"')] +DELTAS = [p for p in PUSHES if p["push"]["channel"] == "orderbook:56:3723"] +PRICES = next(p for p in PUSHES if p["push"]["channel"] == "prices:56:3723") +MARKET = "myriad:56-3723" +WALLET = "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A" +TRADE = { + "marketId": 3723, "networkId": 56, "txHash": "0xt", "blockNumber": 1, "ts": 1719835200123, + "taker": {"trader": "0xtaker", "outcome": 1, "side": "buy", "orderHash": "0xo1", "totalAmount": "2000000000000000000", + "averagePrice": "600000000000000000", "averagePriceAfterFees": "610000000000000000", + "totalFees": {"total": "10000000000000000"}}, + "makers": [{"trader": WALLET, "role": "maker", "outcome": 0, "side": "buy", "orderHash": "0xo2", + "price": "400000000000000000", "priceAfterFees": "400000000000000000", "amount": "2000000000000000000", + "fees": {"total": "0"}}], +} + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +class FakeSocket: + def __init__(self): + self.sent: list = [] + + async def send(self, text): + self.sent.append(json.loads(text)) + + +class FakeCatalog: + """Answers the REST snapshots a book is seeded from, one per outcome.""" + + def __init__(self, books): + self.books = books + self.asked: list = [] + + def _get(self, path, params=None): + self.asked.append((path, params)) + return self.books[params["outcome"]] + + +SNAPSHOTS = { + 0: {"bids": [["320000000000000000", "35714285714285710000"], ["310000000000000000", "1000000000000000000"]], + "asks": [["360000000000000000", "17857142857142854000"]]}, + 1: {"bids": [["640000000000000000", "17857142857142854000"]], + "asks": [["680000000000000000", "35714285714285710000"], ["690000000000000000", "1000000000000000000"]]}, +} + + +async def settle(): + for _ in range(5): + await asyncio.sleep(0) + await asyncio.sleep(0.01) + for _ in range(5): + await asyncio.sleep(0) + + +async def connected(stream): + """A stream past `connect`: the anonymous command sent, the recorded reply read.""" + stream._ws = FakeSocket() + await stream.on_connect() + command = stream._ws.sent[-1] + assert command["connect"] == {} + stream._dispatch(FRAMES[0].replace('"id":1', f'"id":{command["id"]}')) + 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": {}})) + + +class TestRecords: + def test_book_levels(self): + assert book_levels([["510000000000000000", "2000000000000000000"]]) == [(Decimal("0.51"), Decimal("2"))] + + def test_a_match_is_the_takers_print_on_yes(self): + event = trade_event(TRADE) + assert (event.price, event.quantity, event.taker_side) == (Decimal("0.4"), Decimal("2"), Side.SELL) + assert event.market_id == MARKET and event.id == "0xt" + + def test_market_events(self): + assert status_event({"type": "resolved", "networkId": 56, "marketId": 42}).state == "settled" + assert status_event({"type": "created", "networkId": 56, "marketId": 42}).state == "created" + + def test_an_amm_market_or_a_slug_is_refused(self): + stream = MyriadMarketStream() + with pytest.raises(BadRequest, match="AMM"): + asyncio.run(stream.watch_order_book(["myriad:56-amm-120473"])) + with pytest.raises(BadRequest, match="slug"): + asyncio.run(stream.watch_order_book(["myriad:patriots-vs-bears-who-wins"])) + + +class TestMarketStream: + def test_anonymous_connect_then_one_subscribe_per_channel(self): + async def go(): + stream = await connected(MyriadMarketStream(catalog=FakeCatalog(SNAPSHOTS))) + await stream.watch_order_book([MARKET]) + await stream.watch_ticker([MARKET]) + await stream.watch_trades([MARKET]) + return [c["subscribe"]["channel"] for c in stream._ws.sent if "subscribe" in c] + + assert run(go()) == ["orderbook:56:3723", "prices:56:3723", "trades:56:3723"] + + def test_books_seed_from_rest_and_replay_early_deltas(self): + async def go(): + catalog = FakeCatalog(SNAPSHOTS) + stream = await connected(MyriadMarketStream(catalog=catalog)) + await stream.watch_order_book([MARKET]) + command = next(c for c in stream._ws.sent if "subscribe" in c) + stream._dispatch(json.dumps({"id": command["id"], "subscribe": {}})) + # A delta in flight before the snapshot lands is held, then replayed. + stream._dispatch(json.dumps(DELTAS[0])) + held = drain(stream) + await settle() + return stream, catalog, held, drain(stream) + + stream, catalog, held, events = run(go()) + assert not [e for e in held if isinstance(e, BookEvent)], "nothing is applied before the snapshot" + assert [p["outcome"] for _, p in catalog.asked] == [0, 1] + snapshots = [e for e in events if isinstance(e, BookEvent)] + assert [(e.side, e.kind) for e in snapshots] == [("yes", "snapshot"), ("no", "snapshot")] + assert snapshots[0].info["replayed"] == 1 + yes, no = stream.book(MARKET), stream.book(MARKET, "no") + for change in DELTAS[0]["push"]["pub"]["data"]["changes"]: + book = yes if change["outcome"] == 0 else no + levels = book.bids if change["side"] == "bid" else book.asks + price = (Decimal(change["price"]) / Decimal(10) ** 18).quantize(Decimal("0.000001")) + if change["amount"] == "0": + assert price not in levels + else: + assert levels[price] == Decimal(change["amount"]) / Decimal(10) ** 18 + + def test_deltas_after_the_snapshot_change_both_books(self): + async def go(): + stream = await connected(MyriadMarketStream(catalog=FakeCatalog(SNAPSHOTS))) + await stream.watch_order_book([MARKET]) + confirm_subscriptions(stream) + await settle() + drain(stream) + for delta in DELTAS[1:]: + stream._dispatch(json.dumps(delta)) + return stream, drain(stream) + + stream, events = run(go()) + books = [e for e in events if isinstance(e, BookEvent)] + assert books and all(e.kind == "delta" for e in books) and {e.side for e in books} == {"yes", "no"} + yes, no = stream.book(MARKET), stream.book(MARKET, "no") + assert yes.best_ask is not None and no.best_bid is not None + assert yes.best_ask + no.best_bid == Decimal("1"), "the venue's two books stay each other's mirror" + + def test_a_reconnect_invalidates_and_reseeds(self): + async def go(): + catalog = FakeCatalog(SNAPSHOTS) + stream = await connected(MyriadMarketStream(catalog=catalog)) + await stream.watch_order_book([MARKET]) + confirm_subscriptions(stream) + await settle() + stream.on_disconnect() + ready = stream.book(MARKET).ready + await connected(stream) + confirm_subscriptions(stream) + await settle() + return ready, stream, catalog, drain(stream) + + ready, stream, catalog, events = run(go()) + assert not ready and stream.book(MARKET).ready and len(catalog.asked) == 4 + assert any(getattr(e, "state", None) == "resynced" for e in events) + + def test_prices_are_quotes_per_outcome(self): + async def go(): + stream = await connected(MyriadMarketStream()) + await stream.watch_ticker([MARKET]) + stream._dispatch(json.dumps(PRICES)) + return drain(stream) + + quotes = [e for e in run(go()) if isinstance(e, QuoteEvent)] + data = PRICES["push"]["pub"]["data"]["outcomes"] + assert [q.side for q in quotes] == ["yes", "no"] + assert quotes[0].bid == Decimal(data[0]["bestBid"]) / Decimal(10) ** 18 and quotes[0].last is not None + + def test_trades_and_market_changes(self): + async def go(): + stream = await connected(MyriadMarketStream()) + await stream.watch_trades([MARKET]) + await stream.watch_market_status([MARKET]) + stream._dispatch(json.dumps({"push": {"channel": "trades:56:3723", "pub": {"data": TRADE}}})) + stream._dispatch(json.dumps({"push": {"channel": "markets:56", "pub": { + "data": {"type": "resolved", "networkId": 56, "marketId": 3723}}}})) + stream._dispatch(json.dumps({"push": {"channel": "markets:56", "pub": { + "data": {"type": "resolved", "networkId": 56, "marketId": 1}}}})) + return drain(stream) + + events = run(go()) + assert [type(e) for e in events if isinstance(e, (TradeEvent, MarketStatusEvent))] == [TradeEvent, MarketStatusEvent] + + def test_pings_are_answered(self): + async def go(): + stream = await connected(MyriadMarketStream()) + stream._dispatch("{}") + await settle() + return stream._ws.sent[-1] + + assert run(go()) == {} + + +class TestUserStream: + def test_account_channels_carry_a_token_and_fills_do_not(self): + asked = [] + + async def token(channel): + asked.append(channel) + return f"t:{channel}" + + async def go(): + stream = await connected(MyriadUserStream(address=WALLET, token=token)) + await stream.watch_orders() + await stream.watch_my_trades() + await stream.watch_positions() + return [c["subscribe"] for c in stream._ws.sent if "subscribe" in c] + + wallet = WALLET.lower() + assert run(go()) == [ + {"channel": f"orders:56:{wallet}", "token": f"t:orders:56:{wallet}"}, + {"channel": f"trades:56:*:{wallet}"}, + {"channel": f"positions:56:{wallet}", "token": f"t:positions:56:{wallet}"}, + ] + assert asked == [f"orders:56:{wallet}", f"positions:56:{wallet}"] + + def test_orders_fills_and_positions(self): + async def go(): + stream = await connected(MyriadUserStream(address=WALLET, token=lambda channel: "t")) + wallet = WALLET.lower() + stream._dispatch(json.dumps({"push": {"channel": f"orders:56:{wallet}", "pub": {"data": { + "type": "FILLED", "orderHash": "0xo2", "networkId": 56, "marketId": 3723, "trader": WALLET, + "side": "buy", "outcome": 0, "price": "400000000000000000", "amount": "2000000000000000000", + "filledAmount": "2000000000000000000", "status": "filled", "timeInForce": "GTC"}}}})) + stream._dispatch(json.dumps({"push": {"channel": f"trades:56:*:{wallet}", "pub": {"data": TRADE}}})) + for outcome, balance in ((0, "3000000000000000000"), (1, "1000000000000000000")): + stream._dispatch(json.dumps({"push": {"channel": f"positions:56:{wallet}", "pub": {"data": { + "networkId": 56, "marketId": 3723, "trader": WALLET, "outcome": outcome, "delta": "+1", + "balance": balance, "averageEntryPrice": "400000000000000000", "reason": "fill", "ts": 1}}}})) + return drain(stream) + + events = run(go()) + order = next(e.order for e in events if isinstance(e, OrderEvent)) + assert order.status == OrderStatus.CLOSED and order.filled == Decimal("2") and order.market_id == MARKET + fill = next(e.fill for e in events if isinstance(e, FillEvent)) + assert fill.side == Side.BUY and fill.price == Decimal("0.4") and fill.order_id == "0xo2" + first, second = [e.position for e in events if isinstance(e, PositionEvent)] + assert first.inventory_yes == Decimal("3") and first.inventory_no is None + assert second.contracts == Decimal("2") and second.inventory_no == Decimal("1") + + def test_an_account_channel_without_a_token_source_is_reported(self): + async def go(): + stream = await connected(MyriadUserStream(address=WALLET)) + await stream.watch_orders() + return drain(stream), [c for c in stream._ws.sent if "subscribe" in c] + + events, sent = run(go()) + assert not sent and any(getattr(e, "state", None) == "error" for e in events) + + +def run(coro): + return asyncio.run(coro) diff --git a/tests/test_myriad_ws_live.py b/tests/test_myriad_ws_live.py new file mode 100644 index 0000000..adf9cb8 --- /dev/null +++ b/tests/test_myriad_ws_live.py @@ -0,0 +1,29 @@ +"""Myriad's market stream against the real venue: run with `pytest -m live`. +Public channels: no key needed.""" +from __future__ import annotations + +import asyncio + +import pytest + +from synpath import Myriad, MyriadMarketStream +from synpath.ws.base import BookEvent + +pytestmark = pytest.mark.live + + +def test_a_book_arrives_and_mirrors(): + market = max(Myriad().fetch_markets(limit=100, trading_model="ob"), key=lambda m: m.stats.volume_24h or 0) + + async def go(): + async with MyriadMarketStream() as stream: + await stream.watch_order_book([market.id]) + async for event in stream: + if isinstance(event, BookEvent) and event.kind == "snapshot" and event.side == "no": + yes, no = stream.book(market.id), stream.book(market.id, "no") + assert yes.ready and no.ready + if yes.best_ask is not None and no.best_bid is not None: + assert yes.best_ask + no.best_bid == 1 + return + + asyncio.run(asyncio.wait_for(go(), 60)) From 4880867de4f48dd7949c2bb3b68ba4cbf56a83fd Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 18:41:45 +0100 Subject: [PATCH 08/19] fix: combine sports venues The init wizard now asks sixteen venues (Myriad after Azuro, Gemini last): every scripted run answers one question per added venue, and the Myriad, Gemini and Novig setup tests answer at their combined positions. Co-Authored-By: Claude Opus 5.5 --- tests/test_init.py | 42 ++++++++++++++++++------------------- tests/test_novig_trading.py | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/test_init.py b/tests/test_init.py index 0f5edb3..fd2a149 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -41,13 +41,13 @@ def test_it_writes_every_venue_asked_for_readable_by_the_owner_only(tmp_path: Pa "n", # predict.fun "n", # Limitless "n", # SX Bet - "n", # Myriad "n", # Betfair "n", # Smarkets "n", # Novig "n", # Matchbook "n", # ProphetX "n", # Azuro + "n", # Myriad "n"], # Gemini secrets=[KEY, "us-secret"], ) @@ -70,7 +70,7 @@ def test_wrong_values_are_asked_again_with_the_reason(tmp_path: Path): p, said = scripted( ["y", "", "kid-1", str(tmp_path / "missing.pem"), str(not_pem), str(pem), "staging", "demo", "y", "0x1234", WALLET, "7", "1", - "n", "n", "n", "n", "n", "n", "n", "n", "n"], + "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -87,7 +87,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) env = tmp_path / ".env" env.write_text("# mine\nSOMETHING_ELSE=keep\nPOLYMARKET_US_KEY_ID=old\n") os.chmod(env, 0o644) - p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) + p, _ = scripted(["n", "n", "y", "new-key", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["new-secret"]) assert run(env, p) == 0 text = env.read_text() assert "# mine" in text and "SOMETHING_ELSE=keep" in text @@ -97,7 +97,7 @@ def test_an_existing_file_keeps_its_other_lines_and_is_tightened(tmp_path: Path) def test_saying_no_to_everything_writes_nothing(tmp_path: Path): - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"]) assert run(tmp_path / ".env", p) == 0 assert not (tmp_path / ".env").exists() and any("Nothing changed." in line for line in said) @@ -110,7 +110,7 @@ def test_merge_quotes_values_with_spaces_and_updates_in_place(): @pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) + p, said = scripted(["n", "n", "y", "k", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "y"], secrets=["s"]) assert run(tmp_path / ".env", p) == 0 assert (tmp_path / ".gitignore").read_text() == ".env\n" ignored = subprocess.run(["git", "check-ignore", "-q", ".env"], cwd=tmp_path) @@ -119,7 +119,7 @@ def test_inside_a_git_repository_it_offers_to_ignore_the_file(tmp_path: Path): def test_opinion_takes_a_key_it_is_given(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["OPINION_PRIVATE_KEY"] == KEY and values["OPINION_API_KEY"] == "opinion-api-key" @@ -128,7 +128,7 @@ def test_opinion_takes_a_key_it_is_given(tmp_path: Path): def test_opinion_with_an_api_key_needs_no_private_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["opinion-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"OPINION_API_KEY": "opinion-api-key"} assert load_credentials({}, dotenv=env, redact_logs=False)["opinion"] is None, "not enough to trade" @@ -141,7 +141,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch signed_with: list[str] = [] monkeypatch.setattr(opinion, "create_api_key", lambda key: signed_with.append(key) or "created-key") - p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) + p, said = scripted(["n", "n", "n", "y", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "", KEY]) assert run(tmp_path / ".env", p) == 0 assert signed_with == [KEY], "creating a key needs the private key: an empty one is asked again" assert read_dotenv(tmp_path / ".env")["OPINION_API_KEY"] == "created-key" @@ -150,7 +150,7 @@ def test_opinion_creates_the_api_key_when_left_empty(tmp_path: Path, monkeypatch def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "y", "0x1234", WALLET, "maybe", "y", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY and values["HYPERLIQUID_ACCOUNT_ADDRESS"] == WALLET @@ -162,7 +162,7 @@ def test_hyperliquid_takes_an_api_wallet_and_its_account(tmp_path: Path): def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["HYPERLIQUID_PRIVATE_KEY"] == KEY @@ -171,7 +171,7 @@ def test_hyperliquid_with_the_accounts_own_key(tmp_path: Path): def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "0x1234", WALLET, "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["pred_sk_test", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_PRIVATE_KEY"] == KEY and values["PREDICT_FUN_ACCOUNT_ADDRESS"] == WALLET @@ -184,7 +184,7 @@ def test_predict_fun_takes_a_predict_account_and_an_api_key(tmp_path: Path): def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) + p, said = scripted(["n", "n", "n", "n", "n", "y", "", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["", "pred_sk_test", WALLET, ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"PREDICT_FUN_API_KEY": "pred_sk_test"} text = " ".join(said) @@ -196,7 +196,7 @@ def test_predict_fun_with_only_an_api_key_writes_just_the_key(tmp_path: Path): def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n"], secrets=[KEY]) + p, _ = scripted(["n", "n", "n", "n", "n", "y", "y", "", "n", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=[KEY]) assert run(env, p) == 0 values = read_dotenv(env) assert values["PREDICT_FUN_TESTNET"] == "1" and "PREDICT_FUN_API_KEY" not in values @@ -207,7 +207,7 @@ def test_predict_fun_on_the_test_network_needs_no_api_key(tmp_path: Path): def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): env = tmp_path / ".env" secret = "c2VjcmV0LWJ5dGVz" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "y", "token-123", "n", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=[KEY[2:], secret]) assert run(env, p) == 0 values = read_dotenv(env) assert values["LIMITLESS_PRIVATE_KEY"] == KEY and values["LIMITLESS_API_TOKEN_ID"] == "token-123" @@ -218,7 +218,7 @@ def test_limitless_takes_a_wallet_and_an_api_token(tmp_path: Path): def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "y", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["sx-api-key", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values["SX_BET_PRIVATE_KEY"] == KEY and values["SX_BET_API_KEY"] == "sx-api-key" @@ -229,7 +229,7 @@ def test_sx_bet_takes_a_wallet_and_an_api_key(tmp_path: Path): def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n"], secrets=["sx-api-key", ""]) + p, said = scripted(["n", "n", "n", "n", "n", "n", "n", "y", KEY, WALLET, "", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key", "SX_BET_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said), "a key pasted as the address is refused" @@ -240,7 +240,7 @@ def test_sx_bet_with_only_an_api_key_and_the_wallet_address(tmp_path: Path): def test_sx_bet_with_only_an_api_key_writes_just_the_key(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n"], secrets=["sx-api-key", ""]) + p, _ = scripted(["n", "n", "n", "n", "n", "n", "n", "y", "", "", "n", "n", "n", "n", "n", "n", "n", "n"], secrets=["sx-api-key", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"SX_BET_API_KEY": "sx-api-key"} loaded = load_credentials({}, dotenv=env, redact_logs=False)["sx_bet"] @@ -258,7 +258,7 @@ def test_an_api_key_alone_counts_as_set_up_already(tmp_path: Path): def test_myriad_takes_an_api_key_and_the_wallet(tmp_path: Path): env = tmp_path / ".env" - p, _ = scripted(["n"] * 8 + ["y", "key-1", "n", "n"], secrets=["secret-1", KEY[2:]]) + p, _ = scripted(["n"] * 14 + ["y", "key-1", "n"], secrets=["secret-1", KEY[2:]]) assert run(env, p) == 0 values = read_dotenv(env) assert values == {"MYRIAD_API_KEY": "key-1", "MYRIAD_API_SECRET": "secret-1", "MYRIAD_PRIVATE_KEY": KEY} @@ -268,7 +268,7 @@ def test_myriad_takes_an_api_key_and_the_wallet(tmp_path: Path): def test_myriad_without_an_api_key_signs_in_with_the_wallet(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n"] * 8 + ["y", "", "n", "n"], secrets=[KEY]) + p, said = scripted(["n"] * 14 + ["y", "", "n"], secrets=[KEY]) assert run(env, p) == 0 assert read_dotenv(env) == {"MYRIAD_PRIVATE_KEY": KEY} assert any("signs in for a session" in line for line in said) @@ -276,7 +276,7 @@ def test_myriad_without_an_api_key_signs_in_with_the_wallet(tmp_path: Path): def test_myriad_with_only_an_api_key_needs_the_wallet_address(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n"] * 8 + ["y", "key-1", KEY, WALLET, "n", "n"], secrets=["secret-1", ""]) + p, said = scripted(["n"] * 14 + ["y", "key-1", KEY, WALLET, "n"], secrets=["secret-1", ""]) assert run(env, p) == 0 assert read_dotenv(env) == {"MYRIAD_API_KEY": "key-1", "MYRIAD_API_SECRET": "secret-1", "MYRIAD_ACCOUNT_ADDRESS": WALLET} assert any("That looks like a private key" in line for line in said) @@ -286,7 +286,7 @@ def test_myriad_with_only_an_api_key_needs_the_wallet_address(tmp_path: Path): def test_gemini_takes_a_key_pair_and_checks_the_secret_is_not_the_key(tmp_path: Path): env = tmp_path / ".env" - p, said = scripted(["n"] * 10 + ["y", "account-abc", "staging", "sandbox"], secrets=["account-abc", "gem-secret"]) + p, said = scripted(["n"] * 15 + ["y", "account-abc", "staging", "sandbox"], secrets=["account-abc", "gem-secret"]) assert run(env, p) == 0 assert any("the secret is the other half" in line for line in said) assert any("Enter one of: prod, sandbox" in line for line in said) diff --git a/tests/test_novig_trading.py b/tests/test_novig_trading.py index 33afaad..13e4c8d 100644 --- a/tests/test_novig_trading.py +++ b/tests/test_novig_trading.py @@ -564,7 +564,7 @@ def test_init_writes_a_trading_key(self, tmp_path): pem = tmp_path / "desk-1.pem" pem.write_text(ED25519["private_key_pkcs8_pem"]) - answers = iter(["n"] * 10 + ["y", "paper", KEY_ID, str(pem), ""]) + answers = iter(["n"] * 10 + ["y", "paper", KEY_ID, str(pem), ""] + ["n"] * 5) # then Matchbook to Gemini p = Prompter(ask=lambda _q: next(answers), ask_secret=lambda _q: "", say=lambda _: None) env = tmp_path / ".env" assert run(env, p) == 0 From d398d02ba25e8766623ff182943f980121ba6663 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Fri, 23 Oct 2026 18:43:36 +0100 Subject: [PATCH 09/19] fix: serve starts ProphetX when its keys are present Co-Authored-By: Claude Opus 5.5 --- synpath/server/serve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 8925974..462b90e 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -36,7 +36,7 @@ log = logging.getLogger("synpath.serve") VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "novig", - "matchbook", "azuro", "myriad", "gemini") + "matchbook", "prophetx", "azuro", "myriad", "gemini") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: From b7a780688cc2a6630b3e12cf7957f97c6d200917 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 10:15:58 +0000 Subject: [PATCH 10/19] chore: package description names all 16 venues Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 033d26c..70b3064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ backend-path = ["build_backend"] [project] name = "synpath" version = "0.9.0" -description = "One API for 8 prediction markets: Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless and SX Bet. Market data, streaming, self-hosted trading and smart order routing." +description = "One API for 16 prediction markets and sports exchanges: Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Myriad, Gemini, Betfair, Smarkets, Novig, Matchbook, ProphetX and Azuro. Market data, streaming, self-hosted trading and smart order routing." readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } From bd0ea9c8240a095a72cb18e28fb8f32cf0446600 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 10:18:22 +0000 Subject: [PATCH 11/19] chore: prepare synpath 0.10.0 release Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- synpath/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 70b3064..e441b5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ backend-path = ["build_backend"] [project] name = "synpath" -version = "0.9.0" +version = "0.10.0" description = "One API for 16 prediction markets and sports exchanges: Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Myriad, Gemini, Betfair, Smarkets, Novig, Matchbook, ProphetX and Azuro. Market data, streaming, self-hosted trading and smart order routing." readme = "README.md" requires-python = ">=3.10" diff --git a/synpath/__init__.py b/synpath/__init__.py index ee93c9f..ff88c20 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -88,7 +88,7 @@ TradesRangeResponse, ) -__version__ = "0.9.0" +__version__ = "0.10.0" exchanges: dict[str, type[Exchange]] = { "kalshi": Kalshi, From db8bd1281c89af6178014ad7b97b60b7119bd006 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 12/19] feat: betfair-trading Co-Authored-By: Claude Opus 5.5 --- .env.example | 9 +- README.md | 4 +- docs/api.md | 3 +- docs/capabilities.md | 34 +- docs/streaming.md | 24 +- docs/trading.md | 96 +- synpath/__init__.py | 4 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 12 +- synpath/server/serve.py | 4 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 2 +- synpath/trading/betfair.py | 1172 +++++++++++++++++++++ synpath/trading/credentials.py | 10 +- synpath/trading/init.py | 5 +- synpath/trading/instruments.py | 11 +- synpath/ws/__init__.py | 8 +- synpath/ws/betfair.py | 492 +++++++-- tests/samples/betfair_trading.json | 465 ++++++++ tests/samples/betfair_trading_stream.json | 463 ++++++++ tests/test_betfair.py | 6 +- tests/test_betfair_trading.py | 541 ++++++++++ tests/test_betfair_ws.py | 154 ++- 24 files changed, 3396 insertions(+), 135 deletions(-) create mode 100644 synpath/trading/betfair.py create mode 100644 tests/samples/betfair_trading.json create mode 100644 tests/samples/betfair_trading_stream.json create mode 100644 tests/test_betfair_trading.py diff --git a/.env.example b/.env.example index 207b78e..9318118 100644 --- a/.env.example +++ b/.env.example @@ -91,10 +91,11 @@ SX_BET_PRIVATE_KEY= SX_BET_ACCOUNT_ADDRESS= SX_BET_TESTNET= -# Betfair Exchange (market data and streams; no order entry yet): every call, -# market data included, needs your own account and application key. The free -# delayed key (1-180 s late, three price levels) is on the developer program -# page or the Accounts API visualiser. Leave the certificate paths empty for an +# Betfair Exchange (market data, streams and order entry): every call, market +# data included, needs your own account and application key. The free delayed +# key (1-180 s late, three price levels) is on the developer program page or +# the Accounts API visualiser; it reads the account but cannot bet. Betting +# needs the live key, which Betfair activates on request. Leave the certificate paths empty for an # interactive login; set both for Betfair's certificate login. The site the # account is on: com (default), it, es, ro or com.au. Betfair's API is refused # from the US and several other countries, and showing its prices to others diff --git a/README.md b/README.md index 40c5d11..beae791 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [Limitless](https://limitless.exchange) | `limitless` | ✓ | ✓ | WebSocket | one book per market, both sides read it | | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Gemini Predictions](https://www.gemini.com/predictions) | `gemini` | ✓ no account, sports taxonomy | ✓ US accounts with Prediction Markets access | WebSocket (no key for books) | one book per contract, in YES space | -| | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | +| | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | ✓ your own account and a live app key | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | | | [Novig](https://novig.com) | `novig` | ✓ sports, no key | ✓ (US, identity and location checks; Paper for play money) | WebSocket (trading key), else polled from the public book | one book per market, both sides read it | | | [Matchbook](https://www.matchbook.com) | `matchbook` | ✓ no account | ✓ your own login | polled from REST | one book per runner, back and lay read as YES | @@ -89,7 +89,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [Azuro](https://azuro.org) | `azuro` | ✓ sports, no key | ✓ bets with a worst price | WebSocket (no key) | no book: the pool's quoted odds, one level a side sized by its limits | | | [Myriad](https://myriad.markets) | `myriad` | ✓ order-book and AMM markets, sports | ✓ order book | WebSocket (no key) | one book per market, both sides read it; AMM books built from the pool | -Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). +Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key (betting needs Betfair's live key; the free delayed one reads), and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). Gemini Predictions is Gemini Titan's CFTC-registered event-contract exchange. Each contract is a yes/no market named by its instrument symbol, `gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`, and the Gemini event holding the contracts is the event. Sports events carry Gemini's taxonomy (moneyline, spread, total, prop; the sport, scope and statistic), which listings filter on. Market data needs no account; trading needs a Gemini account with Prediction Markets access (US) and an API key. See [capabilities](docs/capabilities.md#gemini). diff --git a/docs/api.md b/docs/api.md index 4de4820..fcc0b25 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,7 +96,8 @@ competition, query)`, the venue's own sports and fixtures as it lists them, and takes `event_type` (an id or a name, `"Soccer"`), `competition`, `event` and `market_type` (`"MATCH_ODDS"`) on `fetch_markets` and `fetch_events`. Every Betfair call needs your own account and application key; see -[Betfair](capabilities.md#betfair). +[Betfair](capabilities.md#betfair). Betting is `BetfairTrading` +([trading](trading.md#betfair)). ```python bf = synpath.Betfair() # BETFAIR_APP_KEY, BETFAIR_USERNAME, BETFAIR_PASSWORD diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..833e5d1 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -113,8 +113,9 @@ session token in memory only, extending it with `keepAlive`. The free side, with no full ladder and no traded volume per runner; every book and market says `info["delayed"]` (Betfair's own `isMarketDataDelayed` when it sends it). Requests are batched to stay inside Betfair's weight limit (200 a -request: best offers weigh 5 a market). There is no order entry for Betfair -yet. +request: best offers weigh 5 a market). Betting (`BetfairTrading`) needs a +live application key, which Betfair activates on request; it refuses bets +from the delayed key. See [trading](trading.md#betfair). **Where it works, and what you may do with it.** Betfair refuses its API from several countries, among them the US, Germany, France, the Netherlands, @@ -527,20 +528,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 200 a request per Betfair market (50 on the Italian exchange), all or none; `market` and `ioc` are bets at the worst price given with the rest cancelled, `fok` its `FILL_OR_KILL`; no `gtd` or post-only | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, sixty a request; `cancel_all_orders` per runner, Betfair market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | yes: a smaller quantity in place (a partial cancel); a new price is the venue's cancel-and-place (`replaceOrders`, a new bet id, the queue place lost) | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; a bet that has left the current orders is found among the cleared ones | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | partial: bets on unsettled markets, by status and placed time | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | partial: one aggregate fill per bet once it has stopped matching, at its average odds; each match is on `BetfairUserStream` | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, matched bets netted per runner; the account's free funds and exposure | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | yes, settled bets from the cleared orders | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's base rate on what the bet wins, makers and takers alike | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | +| `rfq` | yes | no | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -575,6 +576,7 @@ Every stream answers the `watch_*` keys the same way | `SxBetMarketStream` | yes | partial | yes | yes | no | no | no | no | | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | +| `BetfairUserStream` | no | no | no | no | yes | yes, each increase of a bet's matched stake | yes, the matched ladders per runner | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | | `NovigMarketStream` | yes, per order, with a trading key; partial without one: the public book polled every 5 s | yes; partial when polled | yes; partial when polled | yes; partial when polled | no | no | no | no | | `NovigUserStream` | no | no | no | no | yes | yes | yes | no | diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..242ebf2 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -42,6 +42,7 @@ asyncio.run(main()) | `SxBetMarketStream` | `realtime.sx.bet` (or the testnet) | SX Bet API key (every channel needs one) | whole books, top of book, the tape of taker bets, market changes and settlements | | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | +| `BetfairUserStream` | `stream-api.betfair.com:443`, its own connection | the same application key and login | the account's bets on every change; a fill with each increase of a bet's matched stake; matched positions per runner | | `AzuroMarketStream` | `streams.onchainfeed.org` (`dev-streams` for the testnets) | none | each condition's odds as quotes, one-level books sized by the pool's limits, condition status changes | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | | `NovigMarketStream` | `api.novig.com/v3/ws` (or Paper's); without a key, the public REST book polled | a subaccount's trading key for the WebSocket (the handshake is signed); none to poll | books kept per order, top of book, the tape, market transitions (going in play voids resting orders) | @@ -216,7 +217,23 @@ jittered exponential backoff between attempts. 180,000 ms**: at most one update per market every three minutes, whatever `conflate_ms` asks. The stream logs a warning once when it sees that, and waits a whole conflation window before treating silence as a dead socket. - No public trades, so no `watch_trades`; no account stream yet. + No public trades, so no `watch_trades`. + `BetfairUserStream` is the same protocol on a second connection, one + `orderSubscription` for the whole account (`watch_orders(market_ids)` + drops the rest here). Betfair sends every changed bet whole (`uo`), so + each is an `OrderEvent`; an increase in its matched stake is a + `FillEvent` at the odds the increase averaged, with `:` as its id. A runner removal that reduces a matched price, and a + void, change the bet and the position, never add a fill. The matched + ladders per runner (`mb`, `ml`, `[odds, stake]`) are the + `PositionEvent`s, backs as YES contracts and lays as NO. The first image + after subscribing carries only bets still unmatched in part and is the + baseline fills are counted from; a reconnect resumes from the clock where + Betfair can and is reported with `reconcile_required`, and a message + Betfair marks `503` (its order data running late) is reported as an + error with `reconcile_required` until it clears. Customer strategy refs + narrow it (`strategy_refs=`); it can share `BetfairTrading.session`, as + `synpath serve` does, so one login serves the adapter and both streams. - *Azuro* speaks plain JSON on `wss://streams.onchainfeed.org/v1/streams/feed`: `SubscribeConditions` (and `UnsubscribeConditions`) with the condition ids and the chain's environment (`PolygonUSDT`, `BaseWETH`), and a @@ -486,7 +503,10 @@ documented heartbeat and rely on gRPC keepalive. hand-built messages in its documented shapes (no account was available): a TLS-shaped socket carrying CRLF framing, authentication, an image then deltas and deletes, a resubscribe from the clock, heartbeats, status - changes and the 200-market cap. Not yet run against Betfair. + changes and the 200-market cap. The order stream runs on the order change + messages in Betfair's documentation (a market-level snapshot, a + reconnection image, a runner removal, a VAR void and a delayed + heartbeat). Not yet run against Betfair. - **Smarkets:** polls answered from REST quotes recorded anonymously from api.smarkets.com: a snapshot, unchanged polls, deltas, a whole market, diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..d61ed42 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -72,6 +72,7 @@ private key. `synpath init` tells them apart. | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | | Novig | REST: nothing. Books over the WebSocket: a subaccount's trading key (without one, the public book is polled) | a subaccount's trading key: a key id and its private key file (PEM, Ed25519 or P-256). The management key, optional, opens, funds and labels subaccounts and never trades | the management key on novig.com (paper.novig.com for Paper) -> Profile -> Settings -> Novig API; then `NovigTrading.open_subaccount` with a keypair from `novig_signing.generate_key()` issues the trading key. The private key is never sent | +| Betfair | the application key (the free delayed key) and the account's login, for every call | a live application key (Betfair activates it on request) and the same login: username and password, interactive or by a self-signed client certificate | developer.betfair.com (or the Accounts API visualiser) for the keys; for certificate login, a self-signed certificate uploaded at myaccount.betfair.com/accountdetails/mysecurity?showAPI=1 (Automated Betting Program Access) | | Matchbook | nothing | the account's login: username and password (an account with two-factor login passes a session token made elsewhere) | your matchbook.com account; higher API limits need an API plan from api@matchbook.com | | ProphetX | the Trading API key pair, or a Market Data API key | the Trading API key pair: an access key and a secret key, exchanged for a ten-minute bearer token | prophetx.co -> Menu -> API Integration, on any KYC-approved account (sandbox.prophetx.dev for the sandbox); the secret is shown once. The Market Data API key is issued by ProphetX on request | | Azuro | nothing | the private key of the wallet that signs each bet and holds the bet token, which must allow Azuro's relayer to spend it (`approve()`, once) | exported from the wallet | @@ -103,17 +104,12 @@ The variables each venue reads: | Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | +| Betfair | `BETFAIR_APP_KEY` (a live key bets; the delayed key reads market data and the account), `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` (optional together; Betfair's certificate login), `BETFAIR_JURISDICTION` (optional; `com`, the default, `it`, `es`, `ro` or `com.au`, the site the account is on) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | | ProphetX | `PROPHETX_ACCESS_KEY` and `PROPHETX_SECRET_KEY` (the Trading API: market data, streams, orders), `PROPHETX_AFFILIATE_KEY` (optional; the Market Data API, market data only), `PROPHETX_SANDBOX=1` (optional; ProphetX's sandbox, with demo funds) | | Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance and allowance are read from and the approval is sent through; a public one when unset) | | Gemini | `GEMINI_API_KEY` (`account-...`), `GEMINI_API_SECRET`, `GEMINI_ACCOUNT` (optional; the subaccount a `master-...` key acts for, REST only: the WebSocket refuses master keys), `GEMINI_ENV` = `prod` (the default, real money) or `sandbox` (api.sandbox.gemini.com, with a sandbox key) | -Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, -optional `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` and `BETFAIR_JURISDICTION`) -load the same way and `synpath doctor` reports them, but they serve market data -and the stream only: there is no Betfair order entry yet. See -[Betfair](capabilities.md#betfair). - Smarkets needs no credentials for market data. `SMARKETS_USERNAME` and `SMARKETS_PASSWORD` are optional: they log in for undelayed quotes, and `synpath doctor` reports them. There is no Smarkets order entry yet: betting @@ -613,6 +609,94 @@ free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. +## Betfair + +`BetfairTrading`, built to Betfair's Betting API (`placeOrders`, +`cancelOrders`, `replaceOrders`, `updateOrders`, `listCurrentOrders`, +`listClearedOrders`, `listMarketProfitAndLoss`) and Accounts API +(`getAccountFunds`, `getAccountDetails`) documentation. Every call carries +the application key and the session token of the account's login, made the +way the read adapter makes it: by certificate when `BETFAIR_CERT_PATH` and +`BETFAIR_KEY_PATH` are set, else interactively, extended with `keepAlive` +and kept in memory only; a refused session logs in again once. +`session_token=` reuses a login made elsewhere. **Betting needs a live +application key**: Betfair refuses every bet from the delayed key +(`PermissionDenied`), which still reads orders, positions and the balance. + +An order is a bet to back or lay one runner, +`betfair:-` (with `-` on a handicap +runner). `buy` backs it (takes its YES) at decimal odds `1 / price`; `sell` +lays it (takes its NO) at the same odds. Betfair counts both in the backer's +stake, so `n` contracts stake `n / odds`, rounded down to the penny; a lay's +liability is that stake times `odds - 1`. Prices are snapped onto Betfair's +CLASSIC odds ladder (1.01 to 1000) on the cheaper side: a buy at the next +higher odds, a sell at the next lower, so an order never pays more than +asked. A bet stakes at least the currency's minimum bet size (£1, €1, $1) +unless its payout (stake times odds, its contracts) reaches the minimum bet +payout (£10, €20, $20); the payout rule is not offered on the .it and .es +sites. Orders below both are refused before anything is sent. + +Limits (`gtc`) are Betfair's `LIMIT` with persistence `LAPSE`: the unmatched +part is cancelled when the market turns in play. `params={"persistence": +"PERSIST"}` (or `keep_in_play=True`) keeps it in play, and +`"MARKET_ON_CLOSE"` moves it into the starting-price auction at the off. +`fok` is Betfair's `FILL_OR_KILL`, whose price Betfair reads as the worst +*average* matched price; `params={"min_fill": contracts}` accepts a part of +at least that much. Betfair has no immediate-or-cancel bet, so `market` and +`ioc` are `LAPSE` bets at the price given as the worst accepted, whose +unmatched rest is cancelled as soon as Betfair answers. `gtd`, day and +post-only orders are refused, and so is `reduce_only`, since every bet on a +runner nets. `params={"market_version": n}` lapses the bet if the market has +changed materially since version `n` (a runner removed, turned in play). + +On an in-play market Betfair holds a bet for the market's delay (1 to 10 +seconds) before matching it, and answers only then, so `create_order` +returns the bet already matched or resting. `create_orders` sends one +`placeOrders` per Betfair market, 200 bets a request (50 on the Italian +exchange), which Betfair places all or none, and returns one result each. + +`client_order_id` (up to 32 characters) is sent as the bet's +`customerOrderRef`, which Betfair keeps on every record and on the order +stream; a single order's id also de-duplicates a resubmission within 60 +seconds (`customerRef`), refused as `DuplicateClientOrderId`. A placement +Betfair could not finish in time (`TIMEOUT`) is looked up by that id; without +one it raises `RequestTimeout`. `fetch_order_by_client_id` reads a bet by it. + +`edit_order` with a smaller `quantity` (the order's new total, filled +contracts included) is a partial cancel, which keeps the bet's place in the +queue; a new price is `replaceOrders`, which cancels the unmatched part and +places it again at the new odds as a new bet with a new id +(`queue_priority_preserved` is false, `info["replaced"]` names the old bet). +Betfair cannot raise a stake. `update_persistence(order_id, "PERSIST")` +changes what happens at the off in place. Cancels and edits name the bet's +Betfair market; one this adapter did not place is read first to find it. + +Bets on unsettled markets are `fetch_order`, `fetch_open_orders` and +`fetch_orders` (by status: `open`, `closed`, `canceled`); a bet that has left +them (cancelled, lapsed, settled, voided) is found by `fetch_order` among the +cleared orders. Betfair lists bets, not their matched fragments, so +`fetch_my_trades` is one fill per bet at its average matched odds, for bets +that have stopped matching; each match as it happens is on +`BetfairUserStream` ([Streaming](streaming.md)). Settled bets are +`fetch_settlements`, one per bet. Positions net the matched bets per runner +on the YES leg: backs are YES contracts, lays NO contracts. +`fetch_profit_and_loss(market_ids)` is Betfair's own if-win and if-lose +profit per runner. The balance is what is free to bet +(`availableToBetBalance`), what open bets hold (`exposure`, `locked`), and +the two together, in the account's currency. + +Commission is charged at settlement on net winnings per Betfair market, at +the market's base rate (5% on most .com markets), before the account's +discount; `fetch_fee_estimate` is that rate on what one bet would win, an +upper bound for a bet among others on the same market. Betfair charges +accounts that send very many bets and changes (its Transaction Charges), so +batch where you can. + +Check Betfair's terms for where it may be used before trading: its API is +refused from several countries (the US, Germany, France, the Netherlands, +Singapore, India, Turkey, the UAE and others), and its keys are for an +account holder's own betting. + ## Matchbook `MatchbookTrading`, built to Matchbook's API reference diff --git a/synpath/__init__.py b/synpath/__init__.py index ff88c20..da8aee5 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -128,6 +128,7 @@ def exchange(venue: str, **kwargs) -> Exchange: from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, MatchbookTrading, ProphetXTrading, AzuroTrading, MyriadTrading, GeminiTrading, + BetfairTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -157,7 +158,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "BetfairUserStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -197,6 +198,7 @@ def __getattr__(name: str): # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "BetfairTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index a19ec85..610baaa 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -388,6 +388,11 @@ def _novig(creds: Credentials) -> TradingExchange: return NovigTrading(creds) # type: ignore[arg-type] +def _betfair(creds: Credentials) -> TradingExchange: + from .trading.betfair import BetfairTrading + return BetfairTrading(creds) # type: ignore[arg-type] + + def _matchbook(creds: Credentials) -> TradingExchange: from .trading.matchbook import MatchbookTrading return MatchbookTrading(creds) # type: ignore[arg-type] @@ -417,6 +422,7 @@ def _gemini(creds: Credentials) -> TradingExchange: "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, "novig": _novig, + "betfair": _betfair, "matchbook": _matchbook, "prophetx": _prophetx, "azuro": _azuro, diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index 352385a..8202549 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -66,6 +66,7 @@ "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), "novig": ("..trading.novig", "NovigTrading"), + "betfair": ("..trading.betfair", "BetfairTrading"), "matchbook": ("..trading.matchbook", "MatchbookTrading"), "prophetx": ("..trading.prophetx", "ProphetXTrading"), "azuro": ("..trading.azuro", "AzuroTrading"), diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index a4672bf..e22d074 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -168,10 +168,14 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] private=MyriadUserStream(address=address, token=token) if address and token else None, ) elif venue == "betfair" and creds is not None: - # Market data only: no Betfair order entry, so no account stream. - # Every Betfair stream, books included, logs in with the account. - from ..ws.betfair import BetfairMarketStream - out[venue] = VenueStreams(market=BetfairMarketStream(credentials=creds), private=None) + # Every Betfair stream, books included, logs in with the account; + # both share the trading adapter's session, so one login serves all. + from ..ws.betfair import BetfairMarketStream, BetfairUserStream + session = getattr(adapter, "session", None) + out[venue] = VenueStreams( + market=BetfairMarketStream(credentials=creds, session=session), + private=BetfairUserStream(credentials=creds, session=session), + ) elif venue == "gemini": # Books are public; the account stream signs its handshake with # the API key, which must be account-scoped and time-based. diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 462b90e..e1900af 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -35,8 +35,8 @@ log = logging.getLogger("synpath.serve") -VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "novig", - "matchbook", "prophetx", "azuro", "myriad", "gemini") +VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", + "novig", "matchbook", "prophetx", "azuro", "myriad", "gemini") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index a7a2365..e3dac87 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -84,6 +84,9 @@ def __getattr__(name: str): if name == "NovigTrading": from .novig import NovigTrading return NovigTrading + if name == "BetfairTrading": + from .betfair import BetfairTrading + return BetfairTrading if name == "MatchbookTrading": from .matchbook import MatchbookTrading return MatchbookTrading @@ -105,7 +108,7 @@ def __getattr__(name: str): __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", - "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", "BetfairTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index 1746aae..41e2fb4 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -130,7 +130,7 @@ def doctor(dotenv: str | None) -> int: # The application key is a secret too; the username names the account. stage = creds.jurisdiction login = "certificate" if creds.cert_path else "interactive" - identity = f"user {creds.username}, app key loaded, {login} login" + identity = f"user {creds.username}, app key loaded, {login} login (betting needs a live key)" elif venue == "prophetx": stage = "sandbox" if creds.sandbox else "production" affiliate = "; affiliate key loaded: market data from the Market Data API" if creds.affiliate_key else "" diff --git a/synpath/trading/betfair.py b/synpath/trading/betfair.py new file mode 100644 index 0000000..306e674 --- /dev/null +++ b/synpath/trading/betfair.py @@ -0,0 +1,1172 @@ +"""Betfair order entry: bets placed, replaced, reduced and cancelled, and the +account's orders, matched bets, settled bets and funds on the YES leg. + +Built to Betfair's Betting API (`placeOrders`, `cancelOrders`, +`replaceOrders`, `updateOrders`, `listCurrentOrders`, `listClearedOrders`, +`listMarketProfitAndLoss`) and Accounts API (`getAccountFunds`, +`getAccountDetails`) documentation; its records follow the documentation's +examples. Every call carries the application key and the session of the +account's login, interactive or by certificate, exactly as the read adapter +makes it; see `synpath.betfair.BetfairSession`. **Betting needs a live +application key**: Betfair refuses every bet from a delayed key +(`PERMISSION_DENIED`), which still reads the account. + +**An order is a bet to back or lay one runner.** `buy` backs the runner +(takes its YES) at decimal odds 1/price; `sell` lays it (takes its NO) at the +same odds. Betfair counts both in the backer's stake (a lay's `size` is the +backer's stake it accepts): an order for `n` contracts stakes `n / odds`, +rounded down to the penny, and a lay's liability is that stake times +`odds - 1`. Prices are snapped onto Betfair's CLASSIC odds ladder (1.01 to +1000) on the cheaper side: a buy at higher odds (a lower YES price), a sell +at lower odds (a higher YES price). + +**Minimum stakes.** A bet stakes at least the currency's minimum bet size +(£1, €1, $1) unless what it pays out, stake times odds (its contracts), is at +least the currency's minimum bet payout (£10, €20, $20): 10p at 100 is a bet. +The payout rule is not offered on the Italian and Spanish sites. Orders below +both are refused before anything is sent. + +**Persistence and time in force.** A limit (`gtc`) is Betfair's `LIMIT` with +persistence `LAPSE` by default: its unmatched part is cancelled when the +market turns in play. `params={"persistence": "PERSIST"}` (or +`keep_in_play=True`) keeps it in play; `"MARKET_ON_CLOSE"` moves it into the +starting-price auction at the off. `fok` is Betfair's `FILL_OR_KILL`, matched +whole or not at all (`params={"min_fill": contracts}` accepts a part of at +least that much), and Betfair reads its price as the worst *average* price. +Betfair has no immediate-or-cancel bet, so a market order, or an `ioc` +limit, is a `LAPSE` bet at the price given as the worst accepted whose +unmatched rest is cancelled as soon as Betfair answers. GTD, day and +post-only orders are not offered. + +**In-play delay.** On an in-play market Betfair holds a bet for the market's +`betDelay` (1 to 10 seconds) before matching it, and `placeOrders` answers +only after that, so an order returns already matched or resting. A request +Betfair could not finish in time answers `TIMEOUT`; the order is then looked +up by its client order id, which Betfair keeps. + +**Client order ids.** `client_order_id` is sent as the bet's +`customerOrderRef` (up to 32 characters), which every Betfair record and the +order stream carry back, and a single order's id also de-duplicates a +resubmission within 60 seconds (`customerRef`). + +**Edits.** A new price is `replaceOrders`: Betfair cancels the unmatched part +and places it again at the new odds as a new bet with a new id, losing its +place in the queue. A smaller quantity is a partial cancel, which keeps the +place. Betfair cannot raise a bet's stake. `update_persistence` changes what +happens at the off in place (`updateOrders`). + +**Positions** net per runner on the YES leg from the account's matched bets +on unsettled markets: backs are YES contracts, lays NO contracts, a matched +stake S at average odds O being S·O contracts either way. + +**Fees** are commission on the account's net winnings per Betfair market at +settlement, at the market's base rate (5% on most .com markets). +""" +from __future__ import annotations + +import asyncio +import bisect +import re +import time +from decimal import ROUND_DOWN, Decimal +from typing import Any + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..betfair import ( + AUTH_ERRORS, CLASSIC_LADDER, JURISDICTIONS, RATE_ERRORS, SESSION_ERRORS, BetfairSession, api_error_code, + iso_ms, parse_runner_id, runner_native_id, +) +from ..errors import AuthenticationError, BadRequest, ExchangeError, RateLimitExceeded, RequestTimeout +from ..types import Page, iso +from .base import TradingExchange +from .credentials import BetfairCredentials +from .errors import ( + DuplicateClientOrderId, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, + PermissionDenied, +) +from .limiter import BudgetLimiter, Priority +from .types import ( + Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, + Position, PositionSide, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "betfair" +ONE = Decimal("1") +ZERO = Decimal("0") +PRICE_PLACES = Decimal("0.0000000001") +PENNY = Decimal("0.01") + +BETTING_PATH = "/exchange/betting/rest/v1.0" +ACCOUNT_PATH = "/exchange/account/rest/v1.0" + +PLACE_PER_REQUEST = {"it": 50} +"""Place instructions one `placeOrders` request carries: 200 on the global +exchange, 50 on the Italian one.""" +DEFAULT_PLACE_PER_REQUEST = 200 +CANCEL_PER_REQUEST = 60 +"""Cancel, replace and update instructions per request.""" + +PAGE_SIZE = 1000 +"""Records one `listCurrentOrders` or `listClearedOrders` request returns at most.""" + +MAX_PAGES = 50 +"""Most pages one listing call reads.""" + +MAX_IDS = 250 +"""Bet ids and market ids together one `listCurrentOrders` request may name.""" + +REF_LIMIT = 32 +"""Longest `customerOrderRef` and `customerRef` Betfair takes.""" +CUSTOMER_REF = re.compile(r"^[A-Za-z0-9:\-._+*;~]{1,32}$") +"""What a `customerRef` (the request's de-duplication key) may contain.""" + +PERSISTENCE = ("LAPSE", "PERSIST", "MARKET_ON_CLOSE") + +MINIMUMS: dict[str, tuple[Decimal, Decimal]] = { + "GBP": (Decimal("1"), Decimal("10")), "EUR": (Decimal("1"), Decimal("20")), + "USD": (Decimal("1"), Decimal("20")), "AUD": (Decimal("1"), Decimal("30")), + "CAD": (Decimal("2"), Decimal("30")), "HKD": (Decimal("10"), Decimal("125")), + "DKK": (Decimal("15"), Decimal("150")), "NOK": (Decimal("10"), Decimal("150")), + "SEK": (Decimal("15"), Decimal("150")), "SGD": (Decimal("2"), Decimal("30")), + "NZD": (Decimal("2"), Decimal("10")), "RON": (Decimal("5"), Decimal("50")), + "BRL": (Decimal("5"), Decimal("50")), "MXN": (Decimal("30"), Decimal("300")), +} +"""(Min Bet Size, Min Bet Payout) by account currency, from Betfair's +Currency Parameters. A bet is valid at or above the first, or below it when +its payout reaches the second.""" +NO_PAYOUT_RULE = frozenset({"it", "es"}) +"""Sites where the minimum-payout rule is not offered.""" + +CLEARED_STATUSES = ("CANCELLED", "LAPSED", "SETTLED", "VOIDED") +"""The `betStatus` values `fetch_order` searches, in order, for a bet that has +left the current orders.""" + +SIDES = {"BACK": Side.BUY, "LAY": Side.SELL, "B": Side.BUY, "L": Side.SELL} + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return ZERO + return Decimal(str(value)) + + +def yes_price(odds: Any) -> Decimal: + """Decimal odds as the YES price 1/O, to ten places.""" + return (ONE / D(odds)).quantize(PRICE_PLACES) + + +def side_of(native: Any) -> Side: + """`BACK` takes YES; `LAY` takes NO.""" + return SIDES.get(str(native or "").upper(), Side.SELL) + + +def odds_for(side: Side, price: Any) -> Decimal: + """The ladder odds for an order at this YES price, on the cheaper side: a + buy at the first odds at or above 1/price, a sell at the last at or below.""" + p = D(price) + if not 0 < p < 1: + raise InvalidOrder(f"{VENUE}: a price is a YES probability strictly between 0 and 1, not {price}") + # To a millionth of a point of odds, so a price such as 0.3333333333 + # meant as odds 3 is not pushed to the next tick by its last digit. + raw = (ONE / p).quantize(Decimal("0.000001")) + if side == Side.BUY: + index = bisect.bisect_left(CLASSIC_LADDER, raw) + if index >= len(CLASSIC_LADDER): + raise InvalidOrder(f"{VENUE}: {price} is below the ladder's lowest YES price (odds 1000)") + return CLASSIC_LADDER[index] + index = bisect.bisect_right(CLASSIC_LADDER, raw) - 1 + if index < 0: + raise InvalidOrder(f"{VENUE}: {price} is above the ladder's highest YES price (odds 1.01)") + return CLASSIC_LADDER[index] + + +def stake_for(quantity: Any, odds: Decimal) -> Decimal: + """The backer's stake for `quantity` contracts at `odds`, down to the penny.""" + stake = (D(quantity) / odds).quantize(PENNY, rounding=ROUND_DOWN) + if stake <= 0: + raise InvalidOrder(f"{VENUE}: {quantity} contracts at odds {odds} is less than a penny of stake") + return stake + + +def check_minimum(stake: Decimal, odds: Decimal, *, currency: str | None, jurisdiction: str = "com") -> None: + """Refuse a bet below both of the currency's minimums. A currency this + module has no figures for is left for Betfair to judge.""" + minimums = MINIMUMS.get((currency or "").upper()) + if minimums is None: + return + size, payout = minimums + if stake >= size: + return + if jurisdiction not in NO_PAYOUT_RULE and stake * odds >= payout: + return + rule = f"{size} {currency}" + ("" if jurisdiction in NO_PAYOUT_RULE else f", or a payout of {payout} {currency}") + raise InvalidOrder(f"{VENUE}: a stake of {stake} at odds {odds} is below Betfair's minimum bet ({rule})") + + +def _number(value: Decimal) -> float | int: + """A Decimal as the JSON number Betfair reads.""" + return int(value) if value == value.to_integral_value() else float(value) + + +def _runner_id(market_id: Any, selection_id: Any, handicap: Any) -> str: + return ids.qualify(VENUE, runner_native_id(str(market_id), selection_id, handicap or 0)) + + +def _parse_runner(market_id: str) -> tuple[str, int, float]: + native = ids.native(VENUE, market_id) + return parse_runner_id(native) + + +def _market_of(market_id: str) -> str: + """The Betfair market id of a runner id or of a Betfair market (event) id.""" + native = ids.native(VENUE, market_id) + return parse_runner_id(native)[0] if "-" in native else native + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def _price_size(row: dict[str, Any]) -> tuple[Decimal, Decimal]: + """(odds, stake) of a current order or of a place instruction.""" + price_size = row.get("priceSize") or row.get("limitOrder") or (row.get("instruction") or {}).get("limitOrder") or {} + return D(price_size.get("price")), D(price_size.get("size")) + + +def status_of(row: dict[str, Any], *, size: Decimal) -> OrderStatus: + """An order's state from Betfair's status and the amounts on it. A bet no + longer executable ended matched in full (`closed`), killed by its fill or + kill (`expired`), or cancelled or lapsed for the rest (`canceled`).""" + native = str(row.get("status") or row.get("orderStatus") or "").upper() + matched = D(row.get("sizeMatched")) + if native == "PENDING": + return OrderStatus.PENDING + if native == "EXECUTABLE": + return OrderStatus.OPEN + if native == "EXPIRED": + return OrderStatus.EXPIRED + if native == "EXECUTION_COMPLETE": + ended = D(row.get("sizeCancelled")) + D(row.get("sizeLapsed")) + D(row.get("sizeVoided")) + if ended > 0 or (size > 0 and matched < size): + return OrderStatus.CANCELED + return OrderStatus.CLOSED + return OrderStatus.OPEN + + +TIME_IN_FORCE = {"FILL_OR_KILL": TimeInForce.FOK} + + +def order_of_current(row: dict[str, Any], *, account: Account | None = None, currency: str | None = None) -> Order: + """One current order (`listCurrentOrders`, or a stream order in the same + shape). Stakes become contracts at the bet's own odds; what matched is + averaged at its matched odds.""" + odds, size = _price_size(row) + matched, remaining = D(row.get("sizeMatched")), D(row.get("sizeRemaining")) + status = status_of(row, size=size) + average = D(row.get("averagePriceMatched")) + return Order( + id=str(row.get("betId") or ""), + client_order_id=row.get("customerOrderRef") or None, + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + side=side_of(row.get("side")), + type=OrderType.LIMIT, + time_in_force=TIME_IN_FORCE.get(str(row.get("timeInForce") or ""), TimeInForce.GTC), + status=status, + price=yes_price(odds) if odds else None, + quantity=size * odds, + filled=matched * odds, + remaining=remaining * odds if status in (OrderStatus.OPEN, OrderStatus.PENDING) else ZERO, + average_price=yes_price(average) if matched and average else None, + cost=matched if side_of(row.get("side")) == Side.BUY else matched * (average - 1) if average else None, + fee_currency=currency, + created_at=iso_ms(row.get("placedDate")), + updated_at=iso_ms(row.get("matchedDate")), + info=row, + ) + + +def order_of_report( + report: dict[str, Any], *, market_id: str, request: OrderRequest | None = None, account: Account | None = None, + currency: str | None = None, +) -> Order: + """The order from a successful place instruction report. Betfair answers + with the bet id and what matched on arrival; the order's state follows + from those when the report carries none.""" + instruction = report.get("instruction") or {} + limit = instruction.get("limitOrder") or {} + odds, size = D(limit.get("price")), D(limit.get("size")) + matched = D(report.get("sizeMatched")) + native = report.get("orderStatus") + fok = limit.get("timeInForce") == "FILL_OR_KILL" + if native is None: + if matched >= size > 0: + native = "EXECUTION_COMPLETE" + elif fok: + native = "EXPIRED" + else: + native = "EXECUTABLE" + elif fok and native == "EXECUTION_COMPLETE" and matched < size: + native = "EXPIRED" + row = { + "betId": report.get("betId"), "marketId": market_id, "selectionId": instruction.get("selectionId"), + "handicap": instruction.get("handicap"), "priceSize": {"price": limit.get("price"), "size": limit.get("size")}, + "side": instruction.get("side"), "status": native, "persistenceType": limit.get("persistenceType"), + "orderType": instruction.get("orderType"), "placedDate": report.get("placedDate"), + "averagePriceMatched": report.get("averagePriceMatched"), "sizeMatched": report.get("sizeMatched"), + "sizeRemaining": _number(size - matched) if native == "EXECUTABLE" else 0, + "customerOrderRef": instruction.get("customerOrderRef"), "timeInForce": limit.get("timeInForce"), + } + order = order_of_current(row, account=account, currency=currency) + update: dict[str, Any] = {"info": {**report, "marketId": market_id}} + if request is not None: + update.update({ + "client_order_id": request.client_order_id, "type": request.type, "time_in_force": request.time_in_force, + "book": request.book, "trader": request.trader, "tags": request.tags, + }) + if odds and size and matched >= size: + update["remaining"] = ZERO + return order.model_copy(update=update) + + +def order_of_cleared(row: dict[str, Any], *, account: Account | None = None, currency: str | None = None) -> Order: + """A bet that has left the current orders, from `listClearedOrders` at bet + level: cancelled or lapsed (nothing matched), voided, or settled.""" + bet_status = str(row.get("betStatus") or "").upper() + odds = D(row.get("priceRequested")) + settled = D(row.get("sizeSettled")) + left = D(row.get("sizeCancelled")) + matched_odds = D(row.get("priceMatched")) or odds + size = settled + left + status = OrderStatus.CLOSED if bet_status == "SETTLED" and not left else OrderStatus.CANCELED + return Order( + id=str(row.get("betId") or ""), + client_order_id=row.get("customerOrderRef") or None, + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + side=side_of(row.get("side")), + type=OrderType.LIMIT, + time_in_force=TimeInForce.GTC, + status=status, + price=yes_price(odds) if odds else None, + quantity=size * odds, + filled=settled * odds if bet_status == "SETTLED" else ZERO, + remaining=ZERO, + average_price=yes_price(matched_odds) if settled and matched_odds else None, + fee_currency=currency, + created_at=iso_ms(row.get("placedDate")), + updated_at=iso_ms(row.get("settledDate")), + info=row, + ) + + +def fill_of_current(row: dict[str, Any], *, account: Account | None = None, currency: str | None = None) -> Fill | None: + """What has matched on one bet, as one fill at its average matched odds. + Betfair lists bets, not their matched fragments, so the fill carries the + bet's id and its matched stake times those odds in contracts.""" + matched, average = D(row.get("sizeMatched")), D(row.get("averagePriceMatched")) + if not matched or not average: + return None + return Fill( + id=str(row.get("betId") or ""), + order_id=str(row.get("betId") or ""), + client_order_id=row.get("customerOrderRef") or None, + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + side=side_of(row.get("side")), + price=yes_price(average), + quantity=matched * average, + fee=None, + fee_currency=currency, + liquidity=Liquidity.UNKNOWN, + timestamp=iso_ms(row.get("matchedDate")) or iso_ms(row.get("placedDate")) or 0, + info={**row, "aggregated": True}, + ) + + +def positions_of_orders(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: + """Current orders' matched stakes netted per runner on the YES leg: backs + are YES contracts, lays NO contracts, at each bet's average matched odds.""" + held: dict[str, dict[str, Any]] = {} + for row in rows: + matched, average = D(row.get("sizeMatched")), D(row.get("averagePriceMatched")) + if not matched or not average: + continue + synpath_id = _runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")) + entry = held.setdefault(synpath_id, {"yes": ZERO, "no": ZERO, "stake": ZERO, "margin": ZERO, "bets": []}) + back = side_of(row.get("side")) == Side.BUY + entry["yes" if back else "no"] += matched * average + entry["stake"] += matched + entry["margin"] += matched if back else matched * (average - 1) + entry["bets"].append(row.get("betId")) + out = [] + for synpath_id, entry in held.items(): + net = entry["yes"] - entry["no"] + traded = entry["yes"] + entry["no"] + out.append(Position( + venue=VENUE, + account=account, + market_id=synpath_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), + inventory_yes=entry["yes"], + inventory_no=entry["no"], + # The average YES price of every matched bet: stakes over contracts. + entry_price=(entry["stake"] / traded).quantize(PRICE_PLACES) if traded else None, + margin=entry["margin"], + info={"bets": entry["bets"]}, + )) + return out + + +RESULTS = {("BACK", "WON"): "yes", ("BACK", "LOST"): "no", ("LAY", "WON"): "no", ("LAY", "LOST"): "yes", + ("BACK", "PLACE"): "yes", ("LAY", "PLACE"): "yes"} +"""A settled bet's outcome, which is the bet's own (a lay that won means the +runner lost), as the runner's YES result.""" + + +def settlement_of(row: dict[str, Any], *, account: Account | None = None) -> Settlement: + """One settled bet (`listClearedOrders`, bet level).""" + side = str(row.get("side") or "").upper() + odds = D(row.get("priceMatched")) or D(row.get("priceRequested")) + stake = D(row.get("sizeSettled")) + profit = row.get("profit") + return Settlement( + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + held=PositionSide.LONG if side == "BACK" else PositionSide.SHORT, + result=RESULTS.get((side, str(row.get("betOutcome") or "").upper())), + won=(D(profit) > 0) if profit is not None else None, + quantity=stake * odds, + cost=stake if side == "BACK" else None, + pnl=D(profit) if profit is not None else None, + timestamp=iso_ms(row.get("settledDate")), + info=row, + ) + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +INVALID = frozenset({ + "INVALID_BET_SIZE", "INVALID_RUNNER", "INVALID_ODDS", "INVALID_PERSISTENCE_TYPE", "INVALID_BACK_LAY_COMBINATION", + "INVALID_BID_TYPE", "INVALID_PRICE_EDIT", "INVALID_MIN_FILL_SIZE", "INVALID_CUSTOMER_ORDER_REF", + "INVALID_ORDER", "INVALID_MARKET_ID", "INVALID_INPUT_DATA", "TOO_MANY_INSTRUCTIONS", "INVALID_MARKET_VERSION", + "INVALID_PROFIT_RATIO", "INVALID_ORDER_TYPE", "INVALID_PERSISTENCE", +}) +HALTED = frozenset({ + "MARKET_SUSPENDED", "MARKET_NOT_OPEN_FOR_BETTING", "RUNNER_REMOVED", "MARKET_NOT_OPEN_FOR_BSP_BETTING", +}) + + +def error_of(code: str | None, *, what: str, body: Any = None) -> ExchangeError: + """Betfair's refusal of an instruction or a whole request as a typed + error, by its error code.""" + code = str(code or "") + text = f"{VENUE}: {what} refused: {code or 'no reason given'}" + if code == "INSUFFICIENT_FUNDS": + return InsufficientFunds(text, body=body) + if code in HALTED: + return MarketHalted(text, body=body) + if code == "PERMISSION_DENIED": + return PermissionDenied( + f"{text}: Betfair takes bets only on a live application key, and not from restricted countries", + body=body, + ) + if code == "DUPLICATE_TRANSACTION": + return DuplicateClientOrderId(f"{text}: this client order id was sent in the last 60 seconds") + if code == "INVALID_BET_ID": + return OrderNotFound(text, body=body) + if code in INVALID: + return InvalidOrder(text) + return OrderRejected(text, reason=code.lower() or None, info=body if isinstance(body, dict) else {}, body=body) + + +def _failure(report: dict[str, Any], execution: dict[str, Any], *, what: str) -> ExchangeError: + """The error for one failed instruction: its own code, else the request's.""" + code = report.get("errorCode") + if not code or code in ("ERROR_IN_ORDER", "RELATED_ACTION_FAILED"): + code = execution.get("errorCode") if execution.get("errorCode") != "BET_ACTION_ERROR" else code + return error_of(code, what=what, body=report) + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def immediate(request: OrderRequest) -> bool: + """Whether the order takes what is there and leaves nothing resting + (other than a fill or kill, which Betfair holds to that itself).""" + return request.type == OrderType.MARKET or request.time_in_force == TimeInForce.IOC + + +def persistence_of(params: dict[str, Any]) -> str: + """The `persistenceType` a request asks for: `LAPSE` unless it says + otherwise (`persistence=`, or `keep_in_play=True` for `PERSIST`).""" + wanted = params.get("persistence") + if wanted is None: + return "PERSIST" if params.get("keep_in_play") else "LAPSE" + wanted = str(wanted).upper() + if wanted not in PERSISTENCE: + raise InvalidOrder(f"{VENUE}: persistence is LAPSE, PERSIST or MARKET_ON_CLOSE, not {params['persistence']!r}") + return wanted + + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.time_in_force in (TimeInForce.GTD, TimeInForce.DAY): + raise InvalidOrder(f"{VENUE}: Betfair bets rest until matched, cancelled or lapsed; gtc, ioc or fok only") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") + if request.post_only: + raise InvalidOrder(f"{VENUE}: Betfair has no post-only bet") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: Betfair nets every bet on a runner; there is no reduce-only bet") + if request.client_order_id is not None and len(request.client_order_id) > REF_LIMIT: + raise InvalidOrder(f"{VENUE}: a client order id is at most {REF_LIMIT} characters (Betfair's customerOrderRef)") + + +def build_instruction(request: OrderRequest) -> dict[str, Any]: + """One request as a Betfair place instruction, before anything is sent.""" + check_request(request) + _, selection, handicap = _parse_runner(request.market_id) + odds = odds_for(request.side, request.price) + stake = stake_for(request.quantity, odds) + limit: dict[str, Any] = {"size": _number(stake), "price": _number(odds)} + if request.time_in_force == TimeInForce.FOK: + limit["timeInForce"] = "FILL_OR_KILL" + if request.params.get("min_fill") is not None: + limit["minFillSize"] = _number(stake_for(request.params["min_fill"], odds)) + else: + persistence = persistence_of(request.params) + if immediate(request) and persistence != "LAPSE": + raise InvalidOrder(f"{VENUE}: a market or ioc order leaves nothing resting, so it cannot persist") + limit["persistenceType"] = persistence + instruction: dict[str, Any] = { + "selectionId": selection, "handicap": handicap, "side": "BACK" if request.side == Side.BUY else "LAY", + "orderType": "LIMIT", "limitOrder": limit, + } + if request.client_order_id: + instruction["customerOrderRef"] = request.client_order_id + return instruction + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class BetfairTrading(TradingExchange): + """Betfair Exchange order entry, on the account's live application key. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.betfair import BetfairTrading + + async with BetfairTrading(require("betfair", load_credentials())) as bf: + print(await bf.fetch_balance()) + ``` + """ + + id = VENUE + name = "Betfair Exchange" + has: dict[str, Capability] = { + "create_order": True, + # One placeOrders per Betfair market, 200 bets a request; atomic per market. + "create_orders": True, + "cancel_order": True, + "cancel_orders": True, + "cancel_all_orders": True, + # A new price is a new bet (replaceOrders); a smaller quantity a partial cancel. + "edit_order": True, + "fetch_order": True, + "fetch_open_orders": True, + # Bets on markets not yet settled only. + "fetch_orders": "partial", + # One aggregate fill per bet once it has stopped matching, on unsettled markets. + "fetch_my_trades": "partial", + "fetch_positions": True, + "fetch_balance": True, + "fetch_settlements": True, + "fetch_queue_position": False, + "fetch_fee_estimate": True, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: BetfairCredentials | None = None, + *, + session: BetfairSession | None = None, + session_token: str | None = None, + account_name: str = "default", + catalog: Any = None, + limiter: BudgetLimiter | None = None, + timeout: float = 60.0, + client: Any = None, + identity_client: Any = None, + ): + """`credentials` log in on the first call (by certificate when the + certificate and key are set); `session_token` reuses a login made + elsewhere. `timeout` allows for the in-play delay a placement waits.""" + import httpx + + if session is None: + if credentials is None: + from .errors import CredentialsMissing + + raise CredentialsMissing( + f"{VENUE}: set BETFAIR_APP_KEY, BETFAIR_USERNAME and BETFAIR_PASSWORD (a live application key bets)" + ) + session = BetfairSession( + app_key=credentials.app_key, username=credentials.username, password=credentials.password, + cert_path=credentials.cert_path, key_path=credentials.key_path, + jurisdiction=credentials.jurisdiction, session_token=session_token, client=identity_client, + timeout=timeout, + ) + self.credentials = credentials + self.session = session + self.jurisdiction = session.jurisdiction + self.account = Account(venue=VENUE, name=account_name) + # Betfair publishes no request rate for betting, only a charge on + # accounts that send very many transactions; kept modest. + self.limiter = limiter or BudgetLimiter(read_per_second=5, write_per_second=20) + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + shared.headers["Accept"] = "application/json" + self.host = f"https://{JURISDICTIONS[self.jurisdiction]['api']}" + self.http = AsyncHttpClient(self.host, limiter=None, client=shared, venue=VENUE, attempts=2) + self._own_catalog = catalog is None + if catalog is None: + from ..betfair import Betfair + + catalog = Betfair(session=session) + self.catalog = catalog + """The read adapter, on the same session: fee schedules.""" + self._currency: str | None = None + self._markets: dict[str, str] = {} + """Bet id -> Betfair market id, for the cancels and edits Betfair wants a market for.""" + + # -- plumbing ------------------------------------------------------------- + + @property + def place_per_request(self) -> int: + return PLACE_PER_REQUEST.get(self.jurisdiction, DEFAULT_PLACE_PER_REQUEST) + + async def _call( + self, operation: str, body: dict[str, Any], *, api: str = BETTING_PATH, kind: str = "read", + priority: Priority = Priority.NORMAL, + ) -> Any: + """One operation with the session's headers, paced; a refused session + logs in again once. Betfair's own error code names the error.""" + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + for attempt in range(2): + token = await asyncio.to_thread(self.session.token) + headers = {"X-Application": self.session.app_key, "X-Authentication": token, + "Content-Type": "application/json"} + try: + return await self.http.post(f"{api}/{operation}/", json=body, headers=headers) + except ExchangeError as exc: + code = api_error_code(exc.body) + if code in SESSION_ERRORS and attempt == 0: + self.session.invalidate() + continue + if code is None: + raise + if code in AUTH_ERRORS: + raise AuthenticationError(f"{VENUE}: {operation} refused: {code}", body=exc.body, + status=exc.status) from None + if code in RATE_ERRORS: + raise RateLimitExceeded(f"{VENUE}: {operation} refused: {code}") from None + if code in ("PERMISSION_DENIED", "INSUFFICIENT_FUNDS", "DUPLICATE_TRANSACTION") or code in HALTED: + raise error_of(code, what=operation, body=exc.body) from None + raise BadRequest(f"{VENUE}: {operation} refused: {code}", body=exc.body, status=exc.status) from None + raise AssertionError("unreachable") # pragma: no cover + + async def currency(self) -> str: + """The account's currency, read once: every stake and balance is in it.""" + if self._currency is None: + details = await self._call("getAccountDetails", {}, api=ACCOUNT_PATH) or {} + self._currency = str(details.get("currencyCode") or "") + return self._currency + + async def _market_for(self, order_id: str, market_id: str | None) -> str: + """The Betfair market a bet is on: from the caller, from this adapter's + own placements, or read back from the account's orders.""" + if market_id: + return _market_of(market_id) + known = self._markets.get(str(order_id)) + if known: + return known + order = await self.fetch_order(order_id) + return _market_of(order.market_id) + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest) -> Order: + """Place one bet. Betfair answers once it has been matched or put on the + book (after the in-play delay on an in-play market); an immediate + order's unmatched rest is cancelled before this returns.""" + result = await self.create_orders([request]) + if isinstance(result[0], Exception): + raise result[0] + return result[0] + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many bets, one `placeOrders` per Betfair market (200 a request, 50 + on the Italian exchange), one result each in the order asked for. A + refused bet comes back as its exception. Betfair places a request's + bets all or none, so one refused bet fails the others on its market.""" + out: list[Order | Exception | None] = [None] * len(requests) + groups: dict[str, list[tuple[int, dict[str, Any]]]] = {} + currency: str | None = None + for i, request in enumerate(requests): + try: + instruction = build_instruction(request) + limit = instruction["limitOrder"] + if currency is None: + currency = await self.currency() + check_minimum(D(limit["size"]), D(limit["price"]), currency=currency, jurisdiction=self.jurisdiction) + groups.setdefault(_market_of(request.market_id), []).append((i, instruction)) + except (InvalidOrder, BadRequest) as exc: + out[i] = exc + for market, entries in groups.items(): + for start in range(0, len(entries), self.place_per_request): + chunk = entries[start:start + self.place_per_request] + for i, result in zip([i for i, _ in chunk], await self._place(market, chunk, requests)): + out[i] = result + return await self._finish_immediate(requests, out) # type: ignore[arg-type] + + async def _place( + self, market: str, chunk: list[tuple[int, dict[str, Any]]], requests: list[OrderRequest], + ) -> list[Order | Exception]: + body: dict[str, Any] = {"marketId": market, "instructions": [instruction for _, instruction in chunk]} + first = requests[chunk[0][0]] + if len(chunk) == 1 and first.client_order_id and CUSTOMER_REF.match(first.client_order_id): + # One order's id also keys Betfair's 60-second de-duplication. + body["customerRef"] = first.client_order_id + version = first.params.get("market_version") + if version is not None: + # Lapse the bets if the market has changed materially since then + # (a runner removed, turned in play). + body["marketVersion"] = {"version": int(version)} + try: + execution = await self._call("placeOrders", body, kind="write") or {} + except ExchangeError as exc: + return [exc] * len(chunk) + reports = list(execution.get("instructionReports") or []) + currency = self._currency + out: list[Order | Exception] = [] + for n, (i, instruction) in enumerate(chunk): + report = reports[n] if n < len(reports) and isinstance(reports[n], dict) else {} + request = requests[i] + status = report.get("status") or execution.get("status") + if status == "TIMEOUT": + out.append(await self._after_timeout(request)) + elif status == "SUCCESS" and report.get("betId"): + order = order_of_report(report, market_id=market, request=request, + account=request.account or self.account, currency=currency) + self._markets[order.id] = market + out.append(order) + else: + out.append(_failure(report, execution, what="placeOrders")) + return out + + async def _after_timeout(self, request: OrderRequest) -> Order | Exception: + """Betfair did not finish the bet in time: it may yet be placed, or + lapse within fifteen seconds. Look for it by its client order id.""" + if request.client_order_id: + found = await self._current({"customerOrderRefs": [request.client_order_id], "orderProjection": "ALL"}) + if found: + return found[0] + return RequestTimeout( + f"{VENUE}: Betfair timed out placing the bet; its state is unknown for up to 15 seconds -- read the " + f"open orders" + (f" for client order id {request.client_order_id}" if request.client_order_id else "") + ) + + async def _finish_immediate( + self, requests: list[OrderRequest], placed: list[Order | Exception], + ) -> list[Order | Exception]: + """Cancel the unmatched rest of every market or IOC order, so nothing + of them is left resting. Betfair answered after any in-play delay, + so the rest is already on the book.""" + to_cancel = [i for i, (request, order) in enumerate(zip(requests, placed)) + if immediate(request) and isinstance(order, Order) and order.status == OrderStatus.OPEN] + if not to_cancel: + return placed + out = list(placed) + cancelled = await self.cancel_orders([placed[i].id for i in to_cancel]) # type: ignore[union-attr] + for i, result in zip(to_cancel, cancelled): + if isinstance(result, Order): + request = requests[i] + out[i] = result.model_copy(update={ + "client_order_id": request.client_order_id, "type": request.type, + "time_in_force": request.time_in_force, "book": request.book, "trader": request.trader, + "tags": request.tags, + }) + return out + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one bet's unmatched part. One that can no longer be cancelled + (matched, lapsed, already cancelled) is read back as it stands.""" + [result] = await self.cancel_orders([order_id], market_id=market_id) + if isinstance(result, Exception): + raise result + return result + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel bets, sixty a request per Betfair market, each read back after + so the answer carries what matched first.""" + by_market: dict[str, list[str]] = {} + failed: dict[str, Exception] = {} + for order_id in dict.fromkeys(str(o) for o in order_ids): + try: + by_market.setdefault(await self._market_for(order_id, market_id), []).append(order_id) + except OrderNotFound as exc: + failed[order_id] = exc + for market, bets in by_market.items(): + for start in range(0, len(bets), CANCEL_PER_REQUEST): + chunk = bets[start:start + CANCEL_PER_REQUEST] + body = {"marketId": market, "instructions": [{"betId": bet} for bet in chunk]} + execution = await self._call("cancelOrders", body, kind="write", priority=Priority.HIGH) or {} + for report in execution.get("instructionReports") or []: + bet = str((report.get("instruction") or {}).get("betId") or "") + code = report.get("errorCode") + if report.get("status") != "SUCCESS" and code not in (None, "BET_TAKEN_OR_LAPSED"): + failed[bet] = _failure(report, execution, what="cancelOrders") + wanted = [str(o) for o in order_ids if str(o) not in failed] + orders = {o.id: o for o in await self._orders_by_id(wanted)} + out: list[Order | Exception] = [] + for order_id in (str(o) for o in order_ids): + if order_id in failed: + out.append(failed[order_id]) + elif order_id in orders: + out.append(orders[order_id]) + else: + out.append(OrderNotFound(f"{VENUE}: no bet {order_id}")) + return out + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int | None: + """Every unmatched bet on a runner, a Betfair market (`betfair:1.234`) + or the whole account. Returns how many Betfair reports cancelling.""" + if market_id and "-" in ids.native(VENUE, market_id): + bets = [o.id for o in await self.fetch_open_orders(market_id=market_id)] + if not bets: + return 0 + results = await self.cancel_orders(bets, market_id=market_id) + return sum(1 for r in results if isinstance(r, Order)) + body = {"marketId": _market_of(market_id)} if market_id else {} + execution = await self._call("cancelOrders", body, kind="write", priority=Priority.HIGH) or {} + if execution.get("status") not in (None, "SUCCESS", "PROCESSED_WITH_ERRORS"): + raise error_of(execution.get("errorCode"), what="cancelOrders", body=execution) + reports = execution.get("instructionReports") + if reports is None: + return None + return sum(1 for r in reports if isinstance(r, dict) and r.get("status") == "SUCCESS") + + async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order: + """A smaller quantity (a partial cancel, which keeps the bet's place in + the queue) and a new price (`replaceOrders`: the unmatched part placed + again at the new odds, as a new bet with a new id). `quantity` is the + order's new total, filled contracts included; it can only go down. + `update_persistence` changes what happens to it at the off.""" + if request.time_in_force is not None or request.expires_at is not None: + raise InvalidOrder(f"{VENUE}: a bet's time in force cannot be edited; cancel and place again") + current = current or await self.fetch_order(request.order_id) + if current.status not in (OrderStatus.OPEN, OrderStatus.PENDING): + raise InvalidOrder(f"{VENUE}: bet {request.order_id} has nothing unmatched to edit") + market = _market_of(current.market_id) + info = current.info + odds, _ = _price_size(info) + odds = odds or (ONE / current.price if current.price else ZERO) + remaining_stake = D(info.get("sizeRemaining")) if info.get("sizeRemaining") is not None \ + else (current.remaining or ZERO) / odds + order = current + if request.quantity is not None: + left = D(request.quantity) - current.filled + if left <= 0: + raise InvalidOrder(f"{VENUE}: the new quantity leaves nothing unmatched; cancel the bet instead") + new_stake = (left / odds).quantize(PENNY, rounding=ROUND_DOWN) + if new_stake > remaining_stake: + raise InvalidOrder(f"{VENUE}: Betfair cannot raise a bet's stake; place another bet for the rest") + if new_stake < remaining_stake: + reduction = remaining_stake - new_stake + body = {"marketId": market, "instructions": [{"betId": current.id, "sizeReduction": _number(reduction)}]} + await self._edit("cancelOrders", body) + order = (await self.fetch_order(current.id)).model_copy(update={"queue_priority_preserved": True}) + if request.price is not None: + new_odds = odds_for(current.side, request.price) + if new_odds != odds: + body = {"marketId": market, "instructions": [{"betId": current.id, "newPrice": _number(new_odds)}]} + execution = await self._edit("replaceOrders", body) + report = (execution.get("instructionReports") or [{}])[0] + placed = report.get("placeInstructionReport") or {} + if placed.get("status") != "SUCCESS" or not placed.get("betId"): + raise _failure(placed or report, execution, what="replaceOrders") + order = order_of_report(placed, market_id=market, account=self.account, currency=self._currency) + self._markets[order.id] = market + order = order.model_copy(update={ + "queue_priority_preserved": False, + "info": {**order.info, "replaced": current.id}, + }) + return order.model_copy(update={"client_order_id": current.client_order_id}) + + async def update_persistence(self, order_id: str, persistence: str, *, market_id: str | None = None) -> Order: + """What happens to a bet's unmatched part when the market turns in play + (`updateOrders`): `LAPSE`, `PERSIST` or `MARKET_ON_CLOSE`. Changes no + exposure, and keeps the bet's place in the queue.""" + wanted = persistence_of({"persistence": persistence}) + market = await self._market_for(order_id, market_id) + body = {"marketId": market, "instructions": [{"betId": str(order_id), "newPersistenceType": wanted}]} + await self._edit("updateOrders", body) + return await self.fetch_order(order_id) + + async def _edit(self, operation: str, body: dict[str, Any]) -> dict[str, Any]: + execution = await self._call(operation, body, kind="write") or {} + if execution.get("status") == "SUCCESS": + return execution + report = (execution.get("instructionReports") or [{}])[0] + raise _failure(report, execution, what=operation) + + async def fetch_order(self, order_id: str) -> Order: + """One bet by id: among the current orders, then, once it has left + them, among the cancelled, lapsed, settled and voided bets.""" + found = await self._orders_by_id([str(order_id)]) + if found: + return found[0] + raise OrderNotFound(f"{VENUE}: no bet {order_id}") + + async def fetch_order_by_client_id(self, client_order_id: str) -> Order: + """The current bet placed with this client order id (its `customerOrderRef`).""" + found = await self._current({"customerOrderRefs": [client_order_id], "orderProjection": "ALL"}) + if not found: + raise OrderNotFound(f"{VENUE}: no current bet with client order id {client_order_id}") + return found[0] + + async def _orders_by_id(self, order_ids: list[str]) -> list[Order]: + out: list[Order] = [] + # No bet ids would ask for every bet on the account. + order_ids = [o for o in order_ids if o] + for start in range(0, len(order_ids), MAX_IDS): + chunk = order_ids[start:start + MAX_IDS] + current = await self._current({"betIds": chunk, "orderProjection": "ALL"}) + out += current + missing = [o for o in chunk if o not in {order.id for order in current}] + for bet_status in CLEARED_STATUSES: + if not missing: + break + rows, _ = await self._cleared({"betStatus": bet_status, "betIds": missing}, pages=1) + cleared = [order_of_cleared({**row, "betStatus": bet_status}, account=self.account, + currency=self._currency) for row in rows] + out += cleared + missing = [o for o in missing if o not in {order.id for order in cleared}] + for order in out: + self._markets.setdefault(order.id, _market_of(order.market_id)) + return out + + async def _current_rows( + self, params: dict[str, Any], *, offset: int = 0, pages: int = MAX_PAGES, + ) -> tuple[list[dict[str, Any]], int | None]: + """Current order rows from `offset`, page by page; and the next offset + when more are left.""" + rows: list[dict[str, Any]] = [] + for _ in range(pages): + raw = await self._call("listCurrentOrders", {**params, "fromRecord": offset, "recordCount": PAGE_SIZE}) or {} + page = [r for r in raw.get("currentOrders") or [] if isinstance(r, dict)] + rows += page + offset += len(page) + if not page or not raw.get("moreAvailable"): + return rows, None + return rows, offset + + async def _current(self, params: dict[str, Any]) -> list[Order]: + rows, _ = await self._current_rows(params) + return [order_of_current(row, account=self.account, currency=self._currency) for row in rows] + + async def _cleared( + self, params: dict[str, Any], *, offset: int = 0, pages: int = MAX_PAGES, + ) -> tuple[list[dict[str, Any]], int | None]: + rows: list[dict[str, Any]] = [] + for _ in range(pages): + raw = await self._call("listClearedOrders", {**params, "fromRecord": offset, "recordCount": PAGE_SIZE}) or {} + page = [r for r in raw.get("clearedOrders") or [] if isinstance(r, dict)] + rows += page + offset += len(page) + if not page or not raw.get("moreAvailable"): + return rows, None + return rows, offset + + def _scope(self, market_id: str | None) -> tuple[dict[str, Any], str | None]: + """Betfair's market filter for a runner or a Betfair market, and the + runner to keep when it is a runner.""" + if not market_id: + return {}, None + native = ids.native(VENUE, market_id) + if "-" in native: + market, selection, handicap = parse_runner_id(native) + return {"marketIds": [market]}, _runner_id(market, selection, handicap) + return {"marketIds": [native]}, None + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Every bet with an unmatched part, or every one on a runner or a Betfair market.""" + scope, runner = self._scope(market_id) + orders = await self._current({**scope, "orderProjection": "EXECUTABLE"}) + return [o for o in orders if runner is None or o.market_id == runner] + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of bets on unsettled markets, in any state: `open`, + `closed` (matched in full) or `canceled` (the rest cancelled, lapsed + or killed). `since` keeps bets placed since then.""" + projection = {None: "ALL", "open": "EXECUTABLE", "closed": "EXECUTION_COMPLETE", "canceled": "EXECUTION_COMPLETE"} + if status not in projection: + raise BadRequest(f"{VENUE}: status is open, closed or canceled") + scope, runner = self._scope(market_id) + params: dict[str, Any] = {**scope, "orderProjection": projection[status], "orderBy": "BY_PLACE_TIME", + "sortDir": "LATEST_TO_EARLIEST"} + if since is not None: + params["dateRange"] = {"from": iso(since)} + offset = _offset(cursor) + size = min(limit or PAGE_SIZE, PAGE_SIZE) + raw = await self._call("listCurrentOrders", {**params, "fromRecord": offset, "recordCount": size}) or {} + rows = [r for r in raw.get("currentOrders") or [] if isinstance(r, dict)] + orders = [order_of_current(row, account=self.account, currency=self._currency) for row in rows] + if status == "closed": + orders = [o for o in orders if o.status == OrderStatus.CLOSED] + elif status == "canceled": + orders = [o for o in orders if o.status in (OrderStatus.CANCELED, OrderStatus.EXPIRED)] + if runner is not None: + orders = [o for o in orders if o.market_id == runner] + following = offset + len(rows) + return Page(orders, next_cursor=str(following) if rows and raw.get("moreAvailable") else None) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """What has matched on this account's bets in unsettled markets, one + fill per bet at its average matched odds, newest match first. A bet + appears once it has stopped matching (filled, or its rest cancelled + or lapsed), so its fill does not change after it is read; one still + matching is on `BetfairUserStream` and in `fetch_open_orders`.""" + scope, runner = self._scope(market_id) + params: dict[str, Any] = {**scope, "orderProjection": "EXECUTION_COMPLETE", "orderBy": "BY_MATCH_TIME", + "sortDir": "LATEST_TO_EARLIEST"} + if order_id: + params = {"betIds": [str(order_id)], "orderProjection": "ALL"} + if since is not None: + params["dateRange"] = {"from": iso(since)} + rows, following = await self._current_rows(params, offset=_offset(cursor), pages=1) + fills = [f for row in rows if str(row.get("status")) == "EXECUTION_COMPLETE" or order_id + for f in [fill_of_current(row, account=self.account, currency=self._currency)] if f is not None] + if runner is not None: + fills = [f for f in fills if f.market_id == runner] + if since is not None: + fills = [f for f in fills if f.timestamp >= since] + fills.sort(key=lambda f: f.timestamp, reverse=True) + return Page(fills[:limit] if limit else fills, next_cursor=str(following) if following is not None else None) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """What is free to bet (`availableToBetBalance`), what open bets hold + (`exposure`, `locked`), and the two together (`total`).""" + raw = await self._call("getAccountFunds", {}, api=ACCOUNT_PATH) or {} + free = D(raw.get("availableToBetBalance")) + locked = abs(D(raw.get("exposure"))) + return Balance( + venue=VENUE, account=account or self.account, currency=await self.currency(), + total=free + locked, available=free, locked=locked, buying_power=free, + timestamp=int(time.time() * 1000), info=raw, + ) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """The account's matched bets on unsettled markets netted per runner on + the YES leg. `market_id` is a runner, `event_id` a Betfair market.""" + scope, runner = self._scope(market_id or event_id) + rows, _ = await self._current_rows({**scope, "orderProjection": "ALL"}) + positions = positions_of_orders(rows, account=self.account) + return [p for p in positions if p.market_id == runner] if market_id and runner else positions + + async def fetch_profit_and_loss( + self, market_ids: list[str], *, net_of_commission: bool = False, + ) -> list[dict[str, Any]]: + """Betfair's own profit and loss on open markets, if each runner wins + (`ifWin`) or loses (`ifLose`, on markets with several winners), as + `listMarketProfitAndLoss` returns it. Runner ids or Betfair market ids.""" + markets = list(dict.fromkeys(_market_of(m) for m in market_ids)) + return list(await self._call("listMarketProfitAndLoss", { + "marketIds": markets, "includeSettledBets": False, "includeBspBets": True, + "netOfCommission": net_of_commission, + }) or []) + + async def fetch_settlements( + self, *, market_id: str | None = None, since: int | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Settlement]: + """One page of settled bets, one settlement each, as Betfair orders them + by settled date. `since` keeps bets settled since then.""" + scope, runner = self._scope(market_id) + params: dict[str, Any] = {"betStatus": "SETTLED", **scope} + if since is not None: + params["settledDateRange"] = {"from": iso(since)} + rows, following = await self._cleared(params, offset=_offset(cursor), pages=1) + # Bet-level rows name their runner; a roll-up Betfair hoists above it does not. + settlements = [settlement_of(row, account=self.account) for row in rows if row.get("selectionId") is not None] + if runner is not None: + settlements = [s for s in settlements if s.market_id == runner] + return Page(settlements[:limit] if limit else settlements, + next_cursor=str(following) if following is not None else None) + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The commission on `quantity` contracts at the YES `price` if the bet + wins: the market's base rate on its profit. A `sell` lays the runner, + which wins `price` a contract when the runner loses. Makers pay the + same. Betfair charges on net winnings per market, so this is an + upper bound for one bet among others.""" + schedule = await asyncio.to_thread(self.catalog.fetch_fee_schedule, market_id) + paid = D(price) if side == Side.BUY else ONE - D(price) + fee = schedule.estimate(float(paid), float(quantity), taker=True) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, ids.native(VENUE, market_id)), side=side, + price=D(price), quantity=D(quantity), + taker_fee=D(fee) if fee is not None else None, maker_fee=D(fee) if fee is not None else None, + currency=await self.currency(), + info={"schedule": schedule.model_dump(), "market": _market_of(market_id), + "charged": "at settlement, on net winnings in the market, before the account's discount"}, + ) + + async def close(self) -> None: + await self.http.close() + if self._own_catalog: + self.catalog.close() + else: + self.session.close() + + +def _offset(cursor: str | None) -> int: + if not cursor: + return 0 + if not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + return int(cursor) + + +__all__ = [ + "BetfairTrading", "build_instruction", "order_of_current", "order_of_report", "order_of_cleared", + "fill_of_current", "positions_of_orders", "settlement_of", "odds_for", "stake_for", "check_minimum", +] diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..60cb2cf 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -45,9 +45,10 @@ when there is no API key), and optionally MYRIAD_API_KEY with MYRIAD_API_SECRET (HMAC-signed requests), MYRIAD_ACCOUNT_ADDRESS, MYRIAD_RPC_URL - Betfair BETFAIR_APP_KEY, BETFAIR_USERNAME, BETFAIR_PASSWORD, and - (market data) optionally BETFAIR_CERT_PATH / BETFAIR_KEY_PATH - (certificate login) and BETFAIR_JURISDICTION + Betfair BETFAIR_APP_KEY (a live key bets; a delayed one reads), + BETFAIR_USERNAME, BETFAIR_PASSWORD, and optionally + BETFAIR_CERT_PATH / BETFAIR_KEY_PATH (certificate + login) and BETFAIR_JURISDICTION Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market (market data) data needs no login; one gives undelayed quotes Novig NOVIG_KEY_ID, NOVIG_PRIVATE_KEY_PATH (a subaccount's trading @@ -380,7 +381,8 @@ def secrets(self) -> list[str]: class BetfairCredentials: """Betfair: the account's application key and its login. Every Betfair call, market data included, needs both: the key names the application, - the login's session token the account.""" + the login's session token the account. Betting needs a live key; a + delayed key reads market data and the account only.""" app_key: str = field(repr=False) """The application key (delayed or live) from the account's developer diff --git a/synpath/trading/init.py b/synpath/trading/init.py index c4da301..dd74b33 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -275,8 +275,9 @@ def ask_myriad(p: Prompter) -> dict[str, str]: def ask_betfair(p: Prompter) -> dict[str, str]: p.say("\nBetfair: your application key (the delayed key is free: developer.betfair.com, or the Accounts API") - p.say("visualiser) and your Betfair login. Every Betfair call, market data included, logs in. The key and the") - p.say("password are typed hidden. A client certificate (optional) switches to Betfair's certificate login.") + p.say("visualiser; betting needs the live key, which Betfair activates on request) and your Betfair login.") + p.say("Every Betfair call, market data included, logs in. The key and the password are typed hidden. A client") + p.say("certificate (optional) switches to Betfair's certificate login.") app_key = p.value("Application key (typed hidden)", secret=True, check=_required("The application key")) username = p.value("Betfair username", check=_required("The username")) password = p.value("Betfair password (typed hidden)", secret=True, check=_required("The password")) diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 2170ae7..0cee7d2 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -53,11 +53,12 @@ # so contracts take any size to a millionth. "sx_bet": {"default_tick": Decimal("0.00125"), "min_quantity": Decimal("0.000001"), "quantity_step": Decimal("0.000001"), "whole": False, "min_notional": Decimal("1")}, - # No order entry yet; written down for when there is. Prices are decimal - # odds on Betfair's ladder (1.01 to 1000), so the step in YES price runs - # from about 0.0097 near 1.01 to 0.00001 near 1000: the tick here is the - # finest, and an order must still land on the ladder in odds. Stakes are - # in the account's currency, to the penny, at least 1 (GBP) a bet. + # Prices are decimal odds on Betfair's CLASSIC ladder (1.01 to 1000), so + # the step in YES price runs from about 0.0097 near 1.01 to 0.00001 near + # 1000: the tick here is the finest, and the trading adapter snaps each + # order onto the ladder in odds. Stakes are in the account's currency, + # to the penny, at least 1 (GBP) a bet unless its payout is 10 or more, + # which the adapter checks; the notional here is the plain minimum. "betfair": {"default_tick": Decimal("0.00001"), "min_quantity": Decimal("0.01"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("1")}, # No order entry yet; written down for when there is. Prices are basis diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index a9d7c38..a9aacbc 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -53,9 +53,9 @@ def __getattr__(name: str): if name in ("MyriadMarketStream", "MyriadUserStream"): from . import myriad return getattr(myriad, name) - if name == "BetfairMarketStream": - from .betfair import BetfairMarketStream - return BetfairMarketStream + if name in ("BetfairMarketStream", "BetfairUserStream"): + from . import betfair + return getattr(betfair, name) if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream @@ -93,5 +93,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "BetfairUserStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", ] diff --git a/synpath/ws/betfair.py b/synpath/ws/betfair.py index 8159b26..8f9a165 100644 --- a/synpath/ws/betfair.py +++ b/synpath/ws/betfair.py @@ -1,4 +1,5 @@ -"""Betfair's Exchange Stream API: runner books and market status, live. +"""Betfair's Exchange Stream API: runner books and market status, and the +account's bets, matches and positions, live. Not a WebSocket. The stream is one TLS socket to `stream-api.betfair.com:443` carrying JSON messages, one per line, each ended by CRLF. `LineSocket` wraps @@ -33,12 +34,21 @@ books are still right, only late. The stream warns once when it sees it. Betfair publishes no individual trades, so there is no `watch_trades`. + +**The account** (`BetfairUserStream`) is the same protocol on its own +connection: `{"op": "orderSubscription"}` and `{"op": "ocm"}` order change +messages, per Betfair market (`oc`) and runner (`orc`). Each changed bet +(`uo`) is sent whole; the matched ladders per runner (`mb`, `ml`, `[odds, +stake]`, a stake of 0 deleting the point) are deltas. A new subscription's +image carries only bets still unmatched in part; a bet that finishes sends +its `EC` (execution complete) once, then leaves the stream. """ from __future__ import annotations import asyncio import logging import ssl +from collections import OrderedDict from dataclasses import dataclass, field from decimal import Decimal from typing import Any, Callable @@ -46,7 +56,11 @@ from .. import ids from ..base import Capability from ..errors import AuthenticationError -from .base import BookEvent, BookLevel, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms +from ..trading.types import OrderStatus +from .base import ( + BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, + now_ms, +) log = logging.getLogger("synpath.ws.betfair") @@ -162,7 +176,99 @@ def runner_id(market_id: str, key: tuple[int, float]) -> str: return ids.qualify(VENUE, runner_native_id(market_id, key[0], key[1])) -class BetfairMarketStream(Stream): +class _BetfairStream(Stream): + """One Exchange Stream API connection: the TLS socket, authentication and + Betfair's status messages. The market and the order streams are each + one subscription on their own connection.""" + + venue = VENUE + data_heartbeat = True + + def __init__( + self, *, credentials: Any = None, session: Any = None, host: str = STREAM_HOST, port: int = STREAM_PORT, + heartbeat_ms: int = 5000, open_connection: Callable[..., Any] | None = None, + ssl_context: ssl.SSLContext | None = None, **kwargs: Any, + ): + from ..betfair import session_from + + self.session = session or session_from(credentials) + if self.session is None: + raise AuthenticationError( + f"{VENUE}: the stream needs an account: set BETFAIR_APP_KEY, BETFAIR_USERNAME and " + f"BETFAIR_PASSWORD, or pass credentials= or session=" + ) + super().__init__(f"tls://{host}:{port}", **kwargs) + self.host, self.port = host, port + self.heartbeat_ms = heartbeat_ms + self._open_connection = open_connection or asyncio.open_connection + self._ssl = ssl_context or ssl.create_default_context() + self.authenticated = False + self.connection_id: str | None = None + self.initial_clk: str | None = None + self.clk: str | None = None + self._next_id = 0 + self._auth_id: int | None = None + self._sub_id: int | None = None + + # -- connection ----------------------------------------------------------- + + async def _open(self) -> Any: + reader, writer = await self._open_connection( + self.host, self.port, ssl=self._ssl, server_hostname=self.host, limit=LINE_LIMIT, + ) + return LineSocket(reader, writer) + + def _id(self) -> int: + self._next_id += 1 + return self._next_id + + async def on_connect(self) -> None: + self.authenticated = False + token = await asyncio.to_thread(self.session.token) + self._auth_id = self._id() + await self.send({"op": "authentication", "id": self._auth_id, "appKey": self.session.app_key, "session": token}) + + def on_disconnect(self) -> None: + self.authenticated = False + + async def resubscribe(self) -> None: + """Send the subscription, once authenticated.""" + + def subscribed(self) -> None: + """Betfair accepted the subscription.""" + + # -- status messages ------------------------------------------------------ + + def _on_status(self, message: dict[str, Any]) -> list[Event]: + if message.get("statusCode") == "SUCCESS": + if message.get("id") == self._auth_id: + self.authenticated = True + self._later(self.resubscribe()) + elif message.get("id") == self._sub_id: + self.subscribed() + 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._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") + else: + self.status("error", f"Betfair refused: {detail}") + return [] + + def _later(self, coroutine: Any) -> asyncio.Task | None: + try: + return asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + coroutine.close() + return None + + +class BetfairMarketStream(_BetfairStream): """Runner books (best offers) and market status for Betfair markets, over the Exchange Stream API. Needs the account's application key and a session, as every Betfair call does. @@ -179,9 +285,7 @@ class BetfairMarketStream(Stream): ``` """ - venue = VENUE name = "market" - data_heartbeat = True has: dict[str, Capability] = { "watch_order_book": True, # Top of book from the best offers; no last traded price is subscribed. @@ -196,26 +300,16 @@ def __init__( ladder_levels: int = 3, conflate_ms: int | None = None, heartbeat_ms: int = 5000, delayed: bool = True, open_connection: Callable[..., Any] | None = None, ssl_context: ssl.SSLContext | None = None, **kwargs: Any, ): - from ..betfair import session_from - - self.session = session or session_from(credentials) - if self.session is None: - raise AuthenticationError( - f"{VENUE}: the stream needs an account: set BETFAIR_APP_KEY, BETFAIR_USERNAME and " - f"BETFAIR_PASSWORD, or pass credentials= or session=" - ) if not 1 <= ladder_levels <= MAX_LADDER_LEVELS: raise ValueError(f"ladder_levels must be 1 to {MAX_LADDER_LEVELS}") # A delayed stream can be silent for a whole conflation window, so # silence means a dead socket only after one window and some slack. kwargs.setdefault("idle_timeout", DELAYED_CONFLATE_MS / 1000 + 60 if delayed else max(60.0, heartbeat_ms * 3 / 1000)) - super().__init__(f"tls://{host}:{port}", **kwargs) - self.host, self.port = host, port + super().__init__(credentials=credentials, session=session, host=host, port=port, heartbeat_ms=heartbeat_ms, + open_connection=open_connection, ssl_context=ssl_context, **kwargs) self.ladder_levels = ladder_levels - self.conflate_ms, self.heartbeat_ms = conflate_ms, heartbeat_ms + self.conflate_ms = conflate_ms self.delayed = delayed - self._open_connection = open_connection or asyncio.open_connection - self._ssl = ssl_context or ssl.create_default_context() self.markets: dict[str, MarketCache] = {} """Betfair market id -> its cache, kept across reconnects for the resubscribe delta.""" self.books: dict[str, LocalBook] = {} @@ -227,38 +321,13 @@ def __init__( """Betfair market ids the subscription carries.""" 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 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 self._last_state: dict[str, tuple[str, str | None]] = {} self._warned_delayed = False - # -- connection ----------------------------------------------------------- - - async def _open(self) -> Any: - reader, writer = await self._open_connection( - self.host, self.port, ssl=self._ssl, server_hostname=self.host, limit=LINE_LIMIT, - ) - return LineSocket(reader, writer) - - def _id(self) -> int: - self._next_id += 1 - return self._next_id - - async def on_connect(self) -> None: - self.authenticated = False - token = await asyncio.to_thread(self.session.token) - self._auth_id = self._id() - await self.send({"op": "authentication", "id": self._auth_id, "appKey": self.session.app_key, "session": token}) - def on_disconnect(self) -> None: - self.authenticated = False + super().on_disconnect() for book in self.books.values(): book.invalidate() @@ -388,26 +457,8 @@ def handle(self, message: Any) -> list[Event]: return self._on_market_changes(message) return [] - def _on_status(self, message: dict[str, Any]) -> list[Event]: - if message.get("statusCode") == "SUCCESS": - if message.get("id") == self._auth_id: - self.authenticated = True - self._later(self.resubscribe()) - elif message.get("id") == self._sub_id: - self.status("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._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") - else: - self.status("error", f"Betfair refused: {detail}") - return [] + def subscribed(self) -> None: + self.status("subscribed", f"{len(self.wanted)} markets") def _on_market_changes(self, message: dict[str, Any]) -> list[Event]: if message.get("initialClk"): @@ -531,13 +582,6 @@ def _status_changes(self, market: str, definition: dict[str, Any]) -> list[Event )) return events - def _later(self, coroutine: Any) -> asyncio.Task | None: - try: - return asyncio.get_running_loop().create_task(coroutine) - except RuntimeError: - coroutine.close() - return None - def changed(old: tuple[BookLevel, ...], new: tuple[BookLevel, ...]) -> tuple[BookLevel, ...]: """The levels that differ between two sides, a removed one at size 0.""" @@ -546,3 +590,309 @@ def changed(old: tuple[BookLevel, ...], new: tuple[BookLevel, ...]) -> tuple[Boo out = [BookLevel(price, size) for price, size in after.items() if before.get(price) != size] out += [BookLevel(price, Decimal(0)) for price in before if price not in after] return tuple(out) + + +# --------------------------------------------------------------------------- +# The account's orders +# --------------------------------------------------------------------------- + +BET_MEMORY = 8192 +"""Bets whose matched amount the order stream remembers, most recent first.""" + +STREAM_SIDES = {"B": "BACK", "L": "LAY"} +STREAM_STATUS = {"E": "EXECUTABLE", "EC": "EXECUTION_COMPLETE"} +STREAM_PERSISTENCE = {"L": "LAPSE", "P": "PERSIST", "MOC": "MARKET_ON_CLOSE"} +STREAM_ORDER_TYPES = {"L": "LIMIT", "MOC": "MARKET_ON_CLOSE", "LOC": "LIMIT_ON_CLOSE"} + + +def current_row(order: dict[str, Any], *, market: str, selection: Any, handicap: Any) -> dict[str, Any]: + """A stream order (`uo`) in the shape `listCurrentOrders` reports, so the + one reader turns both into an `Order`.""" + from ..types import iso + + def when(key: str) -> str | None: + return iso(int(order[key])) if order.get(key) else None + + return { + "betId": str(order.get("id") or ""), "marketId": market, "selectionId": int(selection), + "handicap": float(handicap or 0), "priceSize": {"price": order.get("p"), "size": order.get("s")}, + "bspLiability": order.get("bsp"), "side": STREAM_SIDES.get(str(order.get("side")), order.get("side")), + "status": STREAM_STATUS.get(str(order.get("status")), order.get("status")), + "persistenceType": STREAM_PERSISTENCE.get(str(order.get("pt")), order.get("pt")), + "orderType": STREAM_ORDER_TYPES.get(str(order.get("ot")), order.get("ot")), + "placedDate": when("pd"), "matchedDate": when("md"), "cancelledDate": when("cd"), "lapsedDate": when("ld"), + "lapseStatusReasonCode": order.get("lsrc"), + "averagePriceMatched": order.get("avp") or 0, "sizeMatched": order.get("sm") or 0, + "sizeRemaining": order.get("sr") or 0, "sizeLapsed": order.get("sl") or 0, + "sizeCancelled": order.get("sc") or 0, "sizeVoided": order.get("sv") or 0, + "regulatorCode": order.get("rc"), "regulatorAuthCode": order.get("rac"), + "customerOrderRef": order.get("rfo") or None, "customerStrategyRef": order.get("rfs") or None, + } + + +@dataclass +class RunnerPosition: + backs: dict[Decimal, Decimal] = field(default_factory=dict) + """`mb`: matched backing stake by matched odds. YES contracts.""" + lays: dict[Decimal, Decimal] = field(default_factory=dict) + """`ml`: matched laying (backer's) stake by matched odds. NO contracts.""" + + +def apply_matches(ladder: dict[Decimal, Decimal], updates: Any) -> None: + """`[odds, stake]` points onto a matched ladder; stake 0 removes the point.""" + for odds, stake in updates or []: + price, size = Decimal(str(odds)), Decimal(str(stake)) + if size == 0: + ladder.pop(price, None) + else: + ladder[price] = size + + +class BetfairUserStream(_BetfairStream): + """The account's bets as they change, what matches on them, and the + matched position on each runner, from the Exchange Stream API's order + subscription. Needs the account's application key and a session, as + every Betfair call does (it can share `BetfairTrading.session`). + + Betfair sends each changed bet in full, so every change is an + `OrderEvent`; what newly matched on it (its matched stake going up) is + a `FillEvent` at the odds the increase averaged, with the bet id and + its matched stake so far as the fill's id. Betfair's matched ladders per + runner (`mb`, `ml`) are the `PositionEvent`s. The first image after a + connection carries only the bets still unmatched in part, and is the + baseline the fills are measured from; a reconnect resumes from the last + clock when Betfair can (`RESUB_DELTA`) and is reported with + `reconcile_required`. + + ```python + async with BetfairUserStream(session=trading.session) as stream: + await stream.watch_orders() + async for event in stream: + ... + ``` + """ + + name = "user" + private = True + has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True, "watch_positions": True} + + def __init__(self, *, credentials: Any = None, session: Any = None, account_name: str = "default", + strategy_refs: list[str] | None = None, **kwargs: Any): + """`strategy_refs` limits the bets streamed to those placed with these + `customerStrategyRef`s (the overall position is not filtered).""" + kwargs.setdefault("idle_timeout", max(60.0, kwargs.get("heartbeat_ms", 5000) * 3 / 1000)) + super().__init__(credentials=credentials, session=session, **kwargs) + from ..trading.types import Account + + self.account = Account(venue=VENUE, name=account_name) + self.strategy_refs = list(strategy_refs or []) + self.kinds: set[str] = set() + """What is watched: orders, fills, positions.""" + self.only: set[str] | None = None + """Betfair market ids or runner ids passed on; `None` for every one.""" + self.orders: dict[str, Any] = {} + """Bet id -> the `Order` as last reported, while it has an unmatched part.""" + self.matched: OrderedDict[str, tuple[Decimal, Decimal]] = OrderedDict() + """Bet id -> (matched stake, average matched odds) as last reported, + the most recent `BET_MEMORY` bets.""" + self.positions: dict[tuple[str, tuple[int, float]], RunnerPosition] = {} + self._degraded = False + + # -- intents -------------------------------------------------------------- + + async def watch_orders(self, market_ids: list[str] | None = None) -> None: + """Every bet on the account, or those on these runners or Betfair + markets. Betfair streams the whole account; the rest is dropped here.""" + await self._watch("orders", market_ids) + + async def watch_my_trades(self, market_ids: list[str] | None = None) -> None: + """What matches on the account's bets, read off their changes.""" + await self._watch("fills", market_ids) + + async def watch_positions(self, market_ids: list[str] | None = None) -> None: + """The matched position on each runner the account has bet on.""" + await self._watch("positions", market_ids) + + async def _watch(self, kind: str, market_ids: list[str] | None) -> None: + if market_ids is None: + self.only = None + elif self.only is not None or not self.kinds: + self.only = (self.only or set()) | {ids.qualify(VENUE, ids.native(VENUE, m)) for m in market_ids} + new = kind not in self.kinds + self.kinds.add(kind) + if new and len(self.kinds) == 1: + await self.resubscribe() + + def _wanted(self, market: str, synpath_id: str) -> bool: + return self.only is None or synpath_id in self.only or f"{VENUE}:{market}" in self.only + + # -- subscription --------------------------------------------------------- + + def subscription(self) -> dict[str, Any]: + """The order subscription, resuming from the last clock when there is one.""" + order_filter: dict[str, Any] = {"includeOverallPosition": True} + if self.strategy_refs: + order_filter["customerStrategyRefs"] = self.strategy_refs + frame: dict[str, Any] = { + "op": "orderSubscription", "id": self._id(), "orderFilter": order_filter, + "segmentationEnabled": True, "heartbeatMs": self.heartbeat_ms, + } + if self.clk and self.initial_clk: + frame["initialClk"], frame["clk"] = self.initial_clk, self.clk + return frame + + async def resubscribe(self) -> None: + if not self.authenticated or not self.kinds: + return + frame = self.subscription() + self._sub_id = frame["id"] + await self.send(frame) + + def subscribed(self) -> None: + self.status("subscribed", "orders") + + # -- messages ------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + op = message.get("op") + if op == "connection": + self.connection_id = message.get("connectionId") + return [] + if op == "status": + return self._on_status(message) + if op == "ocm": + return self._on_order_changes(message) + return [] + + def _on_order_changes(self, message: dict[str, Any]) -> list[Event]: + if message.get("initialClk"): + self.initial_clk = str(message["initialClk"]) + if message.get("clk"): + self.clk = str(message["clk"]) + self._health(message.get("status")) + if message.get("ct") == "HEARTBEAT": + return [] + image = message.get("ct") == "SUB_IMAGE" + if image and message.get("segmentType") in (None, "SEG_START"): + self.orders.clear() + self.positions.clear() + events: list[Event] = [] + for change in message.get("oc") or []: + if isinstance(change, dict) and change.get("id"): + events += self._on_market(change, image=image) + return events + + def _health(self, status: Any) -> None: + """Betfair marks a change message 503 while its order data may be + incomplete, and drops the mark once it has caught up.""" + if status == 503 and not self._degraded: + self._degraded = True + self.status("error", "Betfair says the order stream is delayed (503): bets may be missing until it " + "catches up", reconcile=True) + elif status is None and self._degraded: + self._degraded = False + self.status("resynced", "the order stream caught up") + + def _on_market(self, change: dict[str, Any], *, image: bool) -> list[Event]: + market = str(change["id"]) + if change.get("fullImage"): + for key in [k for k in self.positions if k[0] == market]: + self.positions.pop(key, None) + events: list[Event] = [] + for runner_change in change.get("orc") or []: + if isinstance(runner_change, dict) and runner_change.get("id") is not None: + events += self._on_runner(market, runner_change, image=image or bool(change.get("fullImage"))) + if change.get("closed"): + for key in [k for k in self.positions if k[0] == market]: + self.positions.pop(key, None) + return events + + def _on_runner(self, market: str, change: dict[str, Any], *, image: bool) -> list[Event]: + from ..trading.betfair import order_of_current + + key = runner_key(change["id"], change.get("hc")) + synpath_id = runner_id(market, key) + wanted = self._wanted(market, synpath_id) + events: list[Event] = [] + for row in change.get("uo") or []: + if not isinstance(row, dict) or not row.get("id"): + continue + current = current_row(row, market=market, selection=key[0], handicap=key[1]) + order = order_of_current(current, account=self.account) + order = order.model_copy(update={ + "created_at": int(row["pd"]) if row.get("pd") else order.created_at, + "updated_at": int(row.get("md") or row.get("cd") or row.get("ld") or 0) or order.updated_at, + }) + fill = self._fill(order, row, baseline=image) + if order.status in (OrderStatus.OPEN, OrderStatus.PENDING): + self.orders[order.id] = order + else: + # Its matched amount is kept: a runner removal or a void can + # still change a finished bet, and that is not a new match. + self.orders.pop(order.id, None) + if not wanted: + continue + if fill is not None and "fills" in self.kinds: + events.append(FillEvent(venue=VENUE, fill=fill)) + if "orders" in self.kinds: + if fill is not None: + order = order.model_copy(update={"last_fill_price": fill.price, "last_fill_quantity": fill.quantity}) + events.append(OrderEvent(venue=VENUE, order=order, native=str(row.get("status") or ""))) + if "mb" in change or "ml" in change or change.get("fullImage"): + position = self.positions.setdefault((market, key), RunnerPosition()) + if change.get("fullImage"): + position.backs.clear() + position.lays.clear() + apply_matches(position.backs, change.get("mb")) + apply_matches(position.lays, change.get("ml")) + if wanted and "positions" in self.kinds: + events.append(PositionEvent(venue=VENUE, position=self._position(synpath_id, position))) + return events + + def _fill(self, order: Any, row: dict[str, Any], *, baseline: bool) -> Any: + """What newly matched on a bet since it was last seen, or `None`. A bet + first seen in an image is the baseline; one first seen in an update + matched from nothing.""" + from ..trading.types import Fill, Liquidity + + matched, average = Decimal(str(row.get("sm") or 0)), Decimal(str(row.get("avp") or 0)) + before = self.matched.get(order.id) + self.matched[order.id] = (matched, average) + self.matched.move_to_end(order.id) + while len(self.matched) > BET_MEMORY: + self.matched.popitem(last=False) + if before is None and baseline: + return None + old_stake, old_odds = before or (Decimal(0), Decimal(0)) + stake = matched - old_stake + contracts = matched * average - old_stake * old_odds + if stake <= 0 or contracts <= 0: + return None + return Fill( + id=f"{order.id}:{matched.normalize()}", order_id=order.id, client_order_id=order.client_order_id, + venue=VENUE, account=self.account, market_id=order.market_id, side=order.side, + price=(stake / contracts).quantize(PRICE_PLACES), quantity=contracts, liquidity=Liquidity.UNKNOWN, + timestamp=int(row.get("md") or 0) or now_ms(), + info={"bet_id": order.id, "size_matched": str(matched), "average_odds": str(average)}, + ) + + def _position(self, synpath_id: str, position: RunnerPosition) -> Any: + from ..trading.types import Position, PositionSide + + yes = sum((odds * stake for odds, stake in position.backs.items()), Decimal(0)) + no = sum((odds * stake for odds, stake in position.lays.items()), Decimal(0)) + stake = sum(position.backs.values(), Decimal(0)) + sum(position.lays.values(), Decimal(0)) + margin = sum(position.backs.values(), Decimal(0)) + sum( + (stake_ * (odds - 1) for odds, stake_ in position.lays.items()), Decimal(0)) + net, traded = yes - no, yes + no + return Position( + venue=VENUE, account=self.account, market_id=synpath_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), inventory_yes=yes, inventory_no=no, + entry_price=(stake / traded).quantize(PRICE_PLACES) if traded else None, margin=margin, + timestamp=now_ms(), + info={"matched_backs": [[str(o), str(s)] for o, s in sorted(position.backs.items())], + "matched_lays": [[str(o), str(s)] for o, s in sorted(position.lays.items())]}, + ) diff --git a/tests/samples/betfair_trading.json b/tests/samples/betfair_trading.json new file mode 100644 index 0000000..9eaee86 --- /dev/null +++ b/tests/samples/betfair_trading.json @@ -0,0 +1,465 @@ +{ + "place_lay_request": { + "marketId": "1.109850906", + "instructions": [ + { + "selectionId": "237486", + "handicap": "0", + "side": "LAY", + "orderType": "LIMIT", + "limitOrder": { + "size": "2", + "price": "3", + "persistenceType": "LAPSE" + } + } + ] + }, + "place_lay": { + "marketId": "1.109850906", + "instructionReports": [ + { + "instruction": { + "selectionId": 237486, + "handicap": 0, + "limitOrder": { + "size": 2, + "price": 3, + "persistenceType": "LAPSE" + }, + "orderType": "LIMIT", + "side": "LAY" + }, + "betId": "31242604945", + "placedDate": "2013-10-30T14:22:47.000Z", + "averagePriceMatched": 0, + "sizeMatched": 0, + "status": "SUCCESS" + } + ], + "status": "SUCCESS" + }, + "place_persist": { + "status": "SUCCESS", + "marketId": "1.240636817", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "selectionId": 47999, + "handicap": 0.0, + "limitOrder": { + "size": 2.0, + "price": 10.0, + "persistenceType": "PERSIST" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "383626336337", + "placedDate": "2025-04-02T13:23:40.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0, + "orderStatus": "EXECUTABLE" + } + ] + }, + "place_fok_killed": { + "status": "SUCCESS", + "marketId": "1.126124422", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "selectionId": 10590221, + "handicap": 0.0, + "limitOrder": { + "size": 5.0, + "price": 21.0, + "minFillSize": 5.0, + "timeInForce": "FILL_OR_KILL" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "72666364933", + "placedDate": "2016-08-12T10:45:15.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0 + } + ] + }, + "place_fok_partial": { + "status": "SUCCESS", + "marketId": "1.126124422", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "selectionId": 10590221, + "handicap": 0.0, + "limitOrder": { + "size": 5.0, + "price": 21.0, + "minFillSize": 3.0, + "timeInForce": "FILL_OR_KILL" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "72666433304", + "placedDate": "2016-08-12T10:47:42.000Z", + "averagePriceMatched": 21.32267441860465, + "sizeMatched": 3.4400000000000004 + } + ] + }, + "place_version_rejected": { + "status": "FAILURE", + "errorCode": "BET_ACTION_ERROR", + "marketId": "1.192357130", + "instructionReports": [ + { + "status": "FAILURE", + "errorCode": "BET_TAKEN_OR_LAPSED", + "instruction": { + "selectionId": 3531817, + "handicap": 0.0, + "limitOrder": { + "size": 2.0, + "price": 1000.0, + "persistenceType": "LAPSE" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "253638292039", + "placedDate": "2021-12-15T11:05:58.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0, + "orderStatus": "EXECUTION_COMPLETE" + } + ] + }, + "current_orders": { + "currentOrders": [ + { + "betId": "45496907354", + "marketId": "1.117020524", + "selectionId": 9170340, + "handicap": 0.0, + "priceSize": { + "price": 10.0, + "size": 5.0 + }, + "bspLiability": 0.0, + "side": "BACK", + "status": "EXECUTABLE", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "placedDate": "2015-01-22T13:01:53.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0, + "sizeRemaining": 5.0, + "sizeLapsed": 0.0, + "sizeCancelled": 0.0, + "sizeVoided": 0.0, + "regulatorCode": "GIBRALTAR REGULATOR" + } + ], + "moreAvailable": false + }, + "cleared_orders": { + "clearedOrders": [ + { + "eventTypeId": "1", + "eventId": "28746818", + "marketId": "1.144281274", + "placedDate": "2018-05-29T10:48:50.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "betOutcome": "LOST", + "settledDate": "2018-05-29T19:12:41.000Z", + "lastMatchedDate": "2018-05-29T15:25:26.000Z", + "betCount": 8, + "commission": 0.0, + "priceReduced": false, + "profit": -16.0 + }, + { + "eventTypeId": "2", + "eventId": "28741289", + "marketId": "1.144157120", + "selectionId": 2830908, + "handicap": 0.0, + "betId": "126613673925", + "placedDate": "2018-05-25T08:26:20.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "WON", + "priceRequested": 1.28, + "settledDate": "2018-05-25T10:19:02.000Z", + "lastMatchedDate": "2018-05-25T08:26:40.000Z", + "betCount": 1, + "commission": 0.03, + "priceMatched": 1.28, + "priceReduced": false, + "sizeSettled": 2.0, + "profit": 0.56 + }, + { + "eventTypeId": "7", + "eventId": "28739568", + "marketId": "1.144114832", + "selectionId": 11295111, + "handicap": 0.0, + "placedDate": "2018-05-23T21:27:52.000Z", + "orderType": "LIMIT", + "betOutcome": "WON", + "settledDate": "2018-05-24T13:06:01.000Z", + "lastMatchedDate": "2018-05-24T13:04:24.000Z", + "betCount": 2, + "commission": 0.11, + "priceReduced": true, + "profit": 2.14, + "customerStrategyRef": "HedgerPro_216" + }, + { + "eventTypeId": "7", + "eventId": "28739518", + "marketId": "1.144114517", + "selectionId": 8912176, + "handicap": 0.0, + "betId": "126486489864", + "placedDate": "2018-05-23T15:31:57.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "LOST", + "priceRequested": 11.5, + "settledDate": "2018-05-23T17:12:22.000Z", + "lastMatchedDate": "2018-05-23T15:31:57.000Z", + "betCount": 1, + "commission": 0.0, + "priceMatched": 11.5, + "priceReduced": false, + "sizeSettled": 2.65, + "profit": -2.65 + }, + { + "eventTypeId": "1", + "eventId": "28724382", + "marketId": "1.143781550", + "selectionId": 2081063, + "handicap": 0.0, + "placedDate": "2018-05-21T15:43:12.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "LOST", + "priceRequested": 4.4, + "settledDate": "2018-05-21T20:53:27.000Z", + "lastMatchedDate": "2018-05-21T15:43:12.000Z", + "betCount": 3, + "commission": 0.0, + "priceMatched": 4.4, + "priceReduced": false, + "sizeSettled": 6.0, + "profit": -6.0 + }, + { + "eventTypeId": "1", + "eventId": "28717428", + "marketId": "1.143588270", + "selectionId": 18565, + "handicap": 0.0, + "betId": "125772703658", + "placedDate": "2018-05-16T14:48:53.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "WON", + "priceRequested": 2.18, + "settledDate": "2018-05-16T20:43:37.000Z", + "lastMatchedDate": "2018-05-16T14:48:53.000Z", + "betCount": 1, + "commission": 0.12, + "priceMatched": 2.18, + "priceReduced": false, + "sizeSettled": 2.0, + "profit": 2.36 + }, + { + "eventTypeId": "1", + "eventId": "28722989", + "marketId": "1.143732676", + "selectionId": 198689, + "handicap": 0.0, + "placedDate": "2018-05-14T13:22:56.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "WON", + "priceRequested": 1.42, + "settledDate": "2018-05-14T18:22:07.000Z", + "lastMatchedDate": "2018-05-14T13:22:56.000Z", + "betCount": 2, + "commission": 0.13, + "priceMatched": 1.42, + "priceReduced": false, + "sizeSettled": 6.0, + "profit": 2.52 + } + ], + "moreAvailable": false + }, + "account_funds": { + "availableToBetBalance": 95.5, + "exposure": -4.5, + "retainedCommission": 0.0, + "exposureLimit": -10000.0, + "discountRate": 0.0, + "pointsBalance": 10, + "wallet": "UK" + }, + "account_details": { + "currencyCode": "GBP", + "firstName": "Joe", + "lastName": "Bloggs", + "localeCode": "en", + "region": "GBR", + "timezone": "GMT", + "discountRate": 0.0, + "pointsBalance": 10, + "countryCode": "GB" + }, + "cancel": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354" + }, + "sizeCancelled": 5.0, + "cancelledDate": "2015-01-22T13:05:01.000Z" + } + ] + }, + "cancel_taken": { + "status": "FAILURE", + "errorCode": "BET_ACTION_ERROR", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "FAILURE", + "errorCode": "BET_TAKEN_OR_LAPSED", + "instruction": { + "betId": "45496907354" + } + } + ] + }, + "cancel_partial": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354", + "sizeReduction": 2.0 + }, + "sizeCancelled": 2.0, + "cancelledDate": "2015-01-22T13:05:01.000Z" + } + ] + }, + "replace": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "cancelInstructionReport": { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354" + }, + "sizeCancelled": 5.0, + "cancelledDate": "2015-01-22T13:06:00.000Z" + }, + "placeInstructionReport": { + "status": "SUCCESS", + "instruction": { + "selectionId": 9170340, + "handicap": 0.0, + "limitOrder": { + "size": 5.0, + "price": 12.0, + "persistenceType": "LAPSE" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "45496907999", + "placedDate": "2015-01-22T13:06:00.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0 + } + } + ] + }, + "update": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354", + "newPersistenceType": "PERSIST" + } + } + ] + }, + "profit_and_loss": [ + { + "marketId": "1.117020524", + "profitAndLosses": [ + { + "selectionId": 9170340, + "ifWin": 45.0 + }, + { + "selectionId": 9170341, + "ifWin": -5.0 + } + ] + } + ], + "cleared_cancelled": { + "clearedOrders": [ + { + "eventTypeId": "1", + "eventId": "27312315", + "marketId": "1.117020524", + "selectionId": 9170340, + "handicap": 0.0, + "betId": "45496907354", + "placedDate": "2015-01-22T13:01:53.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "priceRequested": 10.0, + "settledDate": "2015-01-22T13:05:01.000Z", + "betCount": 1, + "priceReduced": false, + "sizeCancelled": 5.0 + } + ], + "moreAvailable": false + } +} diff --git a/tests/samples/betfair_trading_stream.json b/tests/samples/betfair_trading_stream.json new file mode 100644 index 0000000..b586b95 --- /dev/null +++ b/tests/samples/betfair_trading_stream.json @@ -0,0 +1,463 @@ +{ + "subscribe_success": { + "op": "status", + "id": 2, + "statusCode": "SUCCESS", + "connectionClosed": false + }, + "market_snapshot": { + "clk": "AIElAJgiAIYjAMAhAOsm", + "oc": [ + { + "orc": [ + { + "uo": [ + { + "status": "E", + "rfs": "", + "sm": 0, + "pt": "L", + "sr": 2, + "rc": "REG_GGC", + "sv": 0, + "side": "B", + "p": 990, + "s": 2, + "pd": 1603894536000, + "sl": 0, + "sc": 0, + "ot": "L", + "rfo": "", + "id": "215144775671", + "rac": "" + } + ], + "id": 30246, + "fullImage": true + } + ], + "id": "1.174743281", + "fullImage": true + } + ], + "pt": 1603895058618, + "op": "ocm" + }, + "reconnect_image": { + "op": "ocm", + "id": 6, + "initialClk": "GpOH0JwBH762w50BHKKomJ0BGpzR5ZoBH5mWsJwB", + "clk": "AAAAAAAAAAAAAA==", + "conflateMs": 0, + "heartbeatMs": 5000, + "pt": 1468943673782, + "ct": "SUB_IMAGE", + "oc": [ + { + "id": "1.125657695", + "orc": [ + { + "fullImage": true, + "id": 48756, + "mb": [ + [ + 1.4, + 2 + ] + ] + } + ] + }, + { + "id": "1.125657760", + "orc": [ + { + "fullImage": true, + "id": 151478, + "uo": [ + { + "id": "71352090695", + "p": 12, + "s": 5, + "side": "B", + "status": "E", + "pt": "L", + "ot": "L", + "pd": 1468919099000, + "md": 1468933833000, + "avp": 12, + "sm": 4.75, + "sr": 0.25, + "sl": 0, + "sc": 0, + "sv": 0 + } + ], + "mb": [ + [ + 12, + 4.75 + ] + ] + } + ] + } + ] + }, + "reconnect_image_matched": { + "op": "ocm", + "id": 10, + "initialClk": "GtD10ZwBH5OJxZ0BHK75mZ0BGsKq6JoBH4THsZwB", + "clk": "AAAAAAAAAAAAAA==", + "conflateMs": 0, + "heartbeatMs": 5000, + "pt": 1468944647413, + "ct": "SUB_IMAGE", + "oc": [ + { + "id": "1.125670254", + "orc": [ + { + "fullImage": true, + "id": 5643663 + } + ] + }, + { + "id": "1.125657760", + "orc": [ + { + "fullImage": true, + "id": 151478, + "mb": [ + [ + 12, + 5 + ] + ] + } + ] + }, + { + "id": "1.125657695", + "orc": [ + { + "fullImage": true, + "id": 48756, + "mb": [ + [ + 1.4, + 2 + ] + ] + } + ] + } + ] + }, + "rule4_placed": { + "op": "ocm", + "id": 2, + "clk": "AK0CAPsBALEC", + "pt": 1467219304831, + "oc": [ + { + "id": "1.102151675", + "orc": [ + { + "fullImage": true, + "id": 6113662, + "uo": [ + { + "id": "10822867886", + "p": 12, + "s": 2, + "side": "B", + "status": "E", + "pt": "L", + "ot": "L", + "pd": 1467219304000, + "sm": 0, + "sr": 2, + "sl": 0, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC" + } + ] + } + ] + } + ] + }, + "rule4_matched": { + "op": "ocm", + "id": 2, + "clk": "AK0CAPsBALMC", + "pt": 1467219316709, + "oc": [ + { + "id": "1.102151675", + "orc": [ + { + "id": 6113662, + "uo": [ + { + "id": "10822867886", + "p": 12, + "s": 2, + "side": "B", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1467219304000, + "md": 1467219316000, + "avp": 12, + "sm": 2, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 0 + } + ], + "mb": [ + [ + 12, + 2 + ] + ] + } + ] + } + ] + }, + "rule4_reduced": { + "op": "ocm", + "id": 2, + "clk": "AK0CAJACALsC", + "pt": 1467219376611, + "oc": [ + { + "id": "1.102151675", + "orc": [ + { + "id": 6113662, + "uo": [ + { + "id": "10822867886", + "p": 12, + "s": 2, + "side": "B", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1467219304000, + "md": 1467219316000, + "avp": 9.47, + "sm": 2, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 0 + } + ], + "mb": [ + [ + 9.47, + 2 + ], + [ + 12, + 0 + ] + ] + } + ] + } + ] + }, + "var_lay_matched": { + "op": "ocm", + "clk": "AAAAbwBSAE0AAA==", + "status": 503, + "pt": 1740062495457, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 55271, + "uo": [ + { + "id": "309703551064", + "p": 2, + "s": 2, + "side": "L", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1740062487000, + "md": 1740062495000, + "avp": 2, + "sm": 2, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ], + "ml": [ + [ + 2, + 4 + ] + ], + "smc": { + "": { + "ml": [ + [ + 2, + 4 + ] + ] + } + } + } + ] + } + ] + }, + "var_back_placed": { + "op": "ocm", + "clk": "AAAAcgBSAE0AAA==", + "status": 503, + "pt": 1740062516601, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 44787, + "uo": [ + { + "id": "309703551066", + "p": 2, + "s": 2, + "side": "B", + "status": "E", + "pt": "L", + "ot": "L", + "pd": 1740062508000, + "sm": 0, + "sr": 2, + "sl": 0, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ] + } + ] + } + ] + }, + "var_lay_voided": { + "op": "ocm", + "pt": 1740062723421, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 55271, + "uo": [ + { + "id": "309703551064", + "p": 2, + "s": 2, + "side": "L", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1740062487000, + "md": 1740062495000, + "sm": 0, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 2, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ] + } + ] + } + ] + }, + "var_back_lapsed": { + "op": "ocm", + "pt": 1740062723421, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 44787, + "uo": [ + { + "id": "309703551066", + "p": 2, + "s": 2, + "side": "B", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1740062508000, + "ld": 1740062723421, + "sm": 0, + "sr": 0, + "sl": 2, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ] + } + ] + } + ] + }, + "heartbeat_503": { + "op": "ocm", + "id": 3, + "clk": "AAAAAAAA", + "status": 503, + "pt": 1498137379766, + "ct": "HEARTBEAT" + }, + "subscription_example": { + "op": "orderSubscription", + "orderFilter": { + "includeOverallPosition": false, + "customerStrategyRefs": [ + "betstrategy1" + ], + "partitionMatchedByStrategyRef": true + }, + "segmentationEnabled": true + } +} diff --git a/tests/test_betfair.py b/tests/test_betfair.py index 85523f3..1fe5887 100644 --- a/tests/test_betfair.py +++ b/tests/test_betfair.py @@ -532,8 +532,10 @@ def test_no_trades_and_no_candles(self): adapter.fetch_ohlcv(ARSENAL) assert betting.requests == [] - def test_order_entry_is_not_offered(self): - with pytest.raises(NotSupported, match="no order entry"): + def test_order_entry_needs_the_account(self): + from synpath.trading.errors import CredentialsMissing + + with pytest.raises(CredentialsMissing, match="BETFAIR_APP_KEY"): synpath.Client({}).trading("betfair") def test_venue_rules_note_the_minimum_stake(self): diff --git a/tests/test_betfair_trading.py b/tests/test_betfair_trading.py new file mode 100644 index 0000000..fe65479 --- /dev/null +++ b/tests/test_betfair_trading.py @@ -0,0 +1,541 @@ +"""Betfair order entry: bets built from orders, Betfair's records read on the +YES leg, and the adapter against a scripted venue. + +Betting needs an account and a live application key, so nothing here is +recorded. The place, current-order and cleared-order records are the +examples in Betfair's Betting API documentation (placeOrders and Getting +Started), the JSON-RPC envelope taken off as the REST interface answers. +Betfair documents no example for the cancel, replace and update reports, +`getAccountFunds`, `getAccountDetails` or `listMarketProfitAndLoss`; those +are built field by field from its type definitions. Every request goes to +an `httpx.MockTransport`; nothing here reaches Betfair. +""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal +from urllib.parse import parse_qs + +import httpx +import pytest + +from synpath.betfair import BetfairSession, fee_schedule_of +from synpath.errors import BadRequest, RequestTimeout +from synpath.trading.betfair import ( + BetfairTrading, build_instruction, check_minimum, fill_of_current, odds_for, order_of_cleared, order_of_current, + order_of_report, positions_of_orders, settlement_of, stake_for, +) +from synpath.trading.credentials import BetfairCredentials +from synpath.trading.errors import ( + CredentialsMissing, InsufficientFunds, InvalidOrder, MarketHalted, OrderRejected, PermissionDenied, +) +from synpath.trading.types import EditRequest, OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce + +from conftest import load + +S = load("betfair_trading.json") +AUTH = load("betfair_auth.json") +LAY_RUNNER = "betfair:1.109850906-237486" +RUNNER = "betfair:1.117020524-9170340" +BET = "45496907354" +BETTING = "/exchange/betting/rest/v1.0" +ACCOUNT = "/exchange/account/rest/v1.0" + + +@pytest.fixture(autouse=True) +def no_ambient_credentials(monkeypatch, tmp_path): + for name in ("BETFAIR_APP_KEY", "BETFAIR_USERNAME", "BETFAIR_PASSWORD", "BETFAIR_CERT_PATH", + "BETFAIR_KEY_PATH", "BETFAIR_JURISDICTION"): + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + + +def request(**changes) -> OrderRequest: + base = {"market_id": LAY_RUNNER, "side": Side.SELL, "quantity": Decimal("6"), "price": Decimal("0.3333333333")} + return OrderRequest(**{**base, **changes}) + + +def current(**changes) -> dict: + return {**S["current_orders"]["currentOrders"][0], **changes} + + +class Venue: + """Answers Betting and Accounts API operations by name, and records them. + Logins go to the identity host on the same transport.""" + + def __init__(self, **answers): + self.answers = { + "getAccountDetails": S["account_details"], + "login": AUTH["login_success"], "certlogin": AUTH["certlogin_success"], + **answers, + } + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + name = req.url.path.rstrip("/").rsplit("/", 1)[-1] + answer = self.answers.get(name) + if answer is None: + return httpx.Response(404, json={"faultcode": "Client", "faultstring": "not found"}) + if callable(answer): + answer = answer(req) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + def sent(self, operation: str) -> list[dict]: + return [json.loads(r.content) for r in self.requests if r.url.path.rstrip("/").endswith(f"/{operation}")] + + def calls(self, operation: str) -> list[httpx.Request]: + return [r for r in self.requests if r.url.path.rstrip("/").endswith(f"/{operation}")] + + +class Catalog: + def fetch_fee_schedule(self, market_id): + return fee_schedule_of(market_id, 5) + + def close(self): + pass + + +def adapter(venue: Venue, *, session: BetfairSession | None = None, jurisdiction: str = "com", **kwargs) -> BetfairTrading: + transport = httpx.MockTransport(venue) + session = session or BetfairSession(app_key="APP-KEY-LIVE", session_token="SESSION-TOKEN-1", + jurisdiction=jurisdiction, client=httpx.Client(transport=transport)) + return BetfairTrading(session=session, catalog=Catalog(), + client=httpx.AsyncClient(transport=transport), **kwargs) + + +def run(coro): + return asyncio.run(coro) + + +def go(venue: Venue, call, **kwargs): + async def body(): + async with adapter(venue, **kwargs) as bf: + return await call(bf) + + return run(body()) + + +class TestInstructions: + def test_the_documented_lay_is_what_a_sell_builds(self): + # 6 contracts sold at 1/3 is a lay at odds 3 for a backer's stake of 2. + documented = S["place_lay_request"]["instructions"][0] + built = build_instruction(request()) + assert built["side"] == documented["side"] and built["orderType"] == documented["orderType"] + assert built["selectionId"] == int(documented["selectionId"]) and built["handicap"] == 0 + limit = built["limitOrder"] + assert (limit["size"], limit["price"], limit["persistenceType"]) == (2, 3, "LAPSE") + + def test_a_buy_backs_and_carries_the_client_order_id(self): + built = build_instruction(request(side=Side.BUY, price=Decimal("0.1"), quantity=Decimal("20"), + client_order_id="strat-1:42")) + assert built["side"] == "BACK" and built["limitOrder"]["size"] == 2 and built["limitOrder"]["price"] == 10 + assert built["customerOrderRef"] == "strat-1:42" + + def test_odds_snap_to_the_ladder_on_the_cheaper_side(self): + # 1/0.41 = 2.439: a buy pays no more (odds 2.44), a sell receives no less (odds 2.42). + assert odds_for(Side.BUY, Decimal("0.41")) == Decimal("2.44") + assert odds_for(Side.SELL, Decimal("0.41")) == Decimal("2.42") + # Above 100 the ladder steps by 10: 1/0.0075 = 133.3. + assert odds_for(Side.BUY, Decimal("0.0075")) == Decimal("140") + assert odds_for(Side.SELL, Decimal("0.0075")) == Decimal("130") + + def test_prices_off_the_ladder_are_refused(self): + with pytest.raises(InvalidOrder): + odds_for(Side.BUY, Decimal("0.0005")) + with pytest.raises(InvalidOrder): + odds_for(Side.SELL, Decimal("0.995")) + + def test_stakes_round_down_to_the_penny(self): + assert stake_for(Decimal("10"), Decimal("3")) == Decimal("3.33") + with pytest.raises(InvalidOrder, match="penny"): + stake_for(Decimal("0.01"), Decimal("3")) + + def test_persistence_and_fill_or_kill(self): + assert build_instruction(request(params={"persistence": "PERSIST"}))["limitOrder"]["persistenceType"] == "PERSIST" + assert build_instruction(request(params={"keep_in_play": True}))["limitOrder"]["persistenceType"] == "PERSIST" + assert build_instruction(request(params={"persistence": "market_on_close"}))["limitOrder"]["persistenceType"] \ + == "MARKET_ON_CLOSE" + fok = build_instruction(request(side=Side.BUY, price=Decimal("1") / Decimal("21"), quantity=Decimal("105"), + time_in_force=TimeInForce.FOK, params={"min_fill": "63"}))["limitOrder"] + # As the documented fill or kill: 5 at 21, at least 3 of it. + assert fok == {"size": 5, "price": 21, "timeInForce": "FILL_OR_KILL", "minFillSize": 3} + with pytest.raises(InvalidOrder, match="persist"): + build_instruction(request(type=OrderType.MARKET, params={"persistence": "PERSIST"})) + with pytest.raises(InvalidOrder): + build_instruction(request(params={"persistence": "KEEP"})) + + @pytest.mark.parametrize("changes", [ + {"time_in_force": TimeInForce.GTD, "expires_at": 1}, {"post_only": True}, {"reduce_only": True}, + {"type": OrderType.ICEBERG}, {"type": OrderType.MARKET, "price": None}, + {"market_id": "betfair:1.109850906"}, {"client_order_id": "x" * 33}, + ]) + def test_what_betfair_does_not_hold_is_refused(self, changes): + with pytest.raises(BadRequest): # InvalidOrder is a BadRequest + build_instruction(request(**changes)) + + def test_the_minimum_stake_or_the_minimum_payout(self): + check_minimum(Decimal("1"), Decimal("2"), currency="GBP") + # 10p at 100 pays £10: valid by the payout rule, except on .it and .es. + check_minimum(Decimal("0.10"), Decimal("100"), currency="GBP") + with pytest.raises(InvalidOrder, match="payout"): + check_minimum(Decimal("0.10"), Decimal("50"), currency="GBP") + with pytest.raises(InvalidOrder): + check_minimum(Decimal("0.10"), Decimal("100"), currency="EUR", jurisdiction="it") + check_minimum(Decimal("0.10"), Decimal("2"), currency="XYZ") # left to Betfair + + +class TestRecords: + def test_the_documented_placement_rests(self): + order = order_of_report(S["place_lay"]["instructionReports"][0], market_id="1.109850906") + assert order.id == "31242604945" and order.market_id == LAY_RUNNER and order.side == Side.SELL + assert order.status == OrderStatus.OPEN and order.quantity == Decimal("6") + assert order.price == Decimal("0.3333333333") and order.filled == 0 and order.remaining == Decimal("6") + assert order.created_at == 1383142967000 + + def test_an_order_status_on_the_report_wins(self): + order = order_of_report(S["place_persist"]["instructionReports"][0], market_id="1.240636817") + assert order.status == OrderStatus.OPEN and order.side == Side.BUY and order.quantity == Decimal("20") + assert order.info["instruction"]["limitOrder"]["persistenceType"] == "PERSIST" + + def test_fill_or_kill_killed_and_partly_filled(self): + killed = order_of_report(S["place_fok_killed"]["instructionReports"][0], market_id="1.126124422") + assert killed.status == OrderStatus.EXPIRED and killed.filled == 0 and killed.remaining == 0 + assert killed.time_in_force == TimeInForce.FOK + partial = order_of_report(S["place_fok_partial"]["instructionReports"][0], market_id="1.126124422") + assert partial.status == OrderStatus.EXPIRED and partial.quantity == Decimal("105.0") + assert partial.filled == Decimal("3.4400000000000004") * 21 + assert partial.average_price == (1 / Decimal("21.32267441860465")).quantize(Decimal("1e-10")) + + def test_a_current_order(self): + order = order_of_current(S["current_orders"]["currentOrders"][0]) + assert order.id == BET and order.market_id == RUNNER and order.status == OrderStatus.OPEN + assert order.quantity == Decimal("50.0") and order.remaining == Decimal("50.0") and order.price == Decimal("0.1") + + def test_finished_orders_are_closed_or_canceled(self): + matched = order_of_current(current(status="EXECUTION_COMPLETE", sizeMatched=5.0, sizeRemaining=0.0, + averagePriceMatched=10.5)) + assert matched.status == OrderStatus.CLOSED and matched.filled == Decimal("50.0") and matched.remaining == 0 + assert matched.average_price == (1 / Decimal("10.5")).quantize(Decimal("1e-10")) + cancelled = order_of_current(current(status="EXECUTION_COMPLETE", sizeMatched=2.0, sizeRemaining=0.0, + sizeCancelled=3.0, averagePriceMatched=10.0)) + assert cancelled.status == OrderStatus.CANCELED and cancelled.filled == Decimal("20.0") + + def test_a_cancelled_bet_from_the_cleared_orders(self): + order = order_of_cleared({**S["cleared_cancelled"]["clearedOrders"][0], "betStatus": "CANCELLED"}) + assert order.status == OrderStatus.CANCELED and order.quantity == Decimal("50.0") and order.filled == 0 + + def test_a_fill_is_the_bet_at_its_average_matched_odds(self): + fill = fill_of_current(current(status="EXECUTION_COMPLETE", sizeMatched=5.0, averagePriceMatched=10.5, + matchedDate="2015-01-22T13:02:00.000Z")) + assert fill.id == fill.order_id == BET and fill.quantity == Decimal("52.50") + assert fill.price == (1 / Decimal("10.5")).quantize(Decimal("1e-10")) and fill.timestamp == 1421931720000 + assert fill_of_current(current()) is None + + def test_positions_net_backs_and_lays_per_runner(self): + rows = [current(sizeMatched=2.0, averagePriceMatched=12.0), + current(betId="2", side="LAY", sizeMatched=1.0, averagePriceMatched=10.0)] + [position] = positions_of_orders(rows) + assert position.market_id == RUNNER and position.side == PositionSide.LONG + assert (position.inventory_yes, position.inventory_no, position.contracts) == (24, 10, 14) + assert position.margin == Decimal("2.0") + Decimal("9.0") + assert position.entry_price == (Decimal("3") / Decimal("34")).quantize(Decimal("1e-10")) + + def test_settlements_read_the_bets_outcome_as_the_runners(self): + rows = S["cleared_orders"]["clearedOrders"] + won = settlement_of(rows[1]) + assert won.market_id == "betfair:1.144157120-2830908" and won.held == PositionSide.LONG + assert won.result == "yes" and won.won is True and won.quantity == Decimal("2.56") and won.cost == 2 + assert won.pnl == Decimal("0.56") and won.timestamp is not None + lost = settlement_of(rows[3]) + assert lost.result == "no" and lost.won is False and lost.pnl == Decimal("-2.65") + lay = settlement_of({**rows[3], "side": "LAY", "betOutcome": "WON"}) + assert lay.held == PositionSide.SHORT and lay.result == "no" and lay.cost is None + + +class TestPlacing: + def test_a_bet_is_placed_with_the_session_and_its_client_id(self): + venue = Venue(placeOrders=S["place_lay"]) + order = go(venue, lambda bf: bf.create_order(request(client_order_id="c-1"))) + [place] = venue.calls("placeOrders") + assert place.url.path == f"{BETTING}/placeOrders/" + assert place.headers["X-Application"] == "APP-KEY-LIVE" and place.headers["X-Authentication"] == "SESSION-TOKEN-1" + assert venue.calls("getAccountDetails")[0].url.path == f"{ACCOUNT}/getAccountDetails/" + [body] = venue.sent("placeOrders") + assert body["marketId"] == "1.109850906" and body["customerRef"] == "c-1" + assert body["instructions"][0]["customerOrderRef"] == "c-1" + assert order.status == OrderStatus.OPEN and order.client_order_id == "c-1" and order.id == "31242604945" + + def test_a_bet_below_the_minimum_is_refused_before_sending(self): + venue = Venue(placeOrders=S["place_lay"]) + with pytest.raises(InvalidOrder, match="minimum"): + go(venue, lambda bf: bf.create_order(request(quantity=Decimal("1.5")))) + assert venue.calls("placeOrders") == [] + + def test_a_market_order_cancels_its_unmatched_rest(self): + cancelled = current(betId="31242604945", marketId="1.109850906", selectionId=237486, side="LAY", + priceSize={"price": 3.0, "size": 2.0}, status="EXECUTION_COMPLETE", + sizeRemaining=0.0, sizeCancelled=2.0) + venue = Venue(placeOrders=S["place_lay"], cancelOrders={**S["cancel"], "marketId": "1.109850906"}, + listCurrentOrders={"currentOrders": [cancelled], "moreAvailable": False}) + order = go(venue, lambda bf: bf.create_order(request(type=OrderType.MARKET))) + [cancel] = venue.sent("cancelOrders") + assert cancel == {"marketId": "1.109850906", "instructions": [{"betId": "31242604945"}]} + assert order.status == OrderStatus.CANCELED and order.type == OrderType.MARKET + + def test_a_fill_or_kill_is_left_to_betfair(self): + venue = Venue(placeOrders=S["place_fok_killed"]) + order = go(venue, lambda bf: bf.create_order(request( + market_id="betfair:1.126124422-10590221", side=Side.BUY, price=Decimal(1) / 21, quantity=Decimal("105"), + time_in_force=TimeInForce.FOK))) + assert order.status == OrderStatus.EXPIRED and venue.calls("cancelOrders") == [] + + def test_a_timeout_is_looked_up_by_the_client_order_id(self): + timeout = {"status": "TIMEOUT", "marketId": "1.109850906", "instructionReports": [{"status": "TIMEOUT"}]} + found = current(betId="31242604945", marketId="1.109850906", selectionId=237486, customerOrderRef="c-9") + venue = Venue(placeOrders=timeout, listCurrentOrders={"currentOrders": [found], "moreAvailable": False}) + order = go(venue, lambda bf: bf.create_order(request(client_order_id="c-9"))) + assert order.id == "31242604945" and venue.sent("listCurrentOrders")[0]["customerOrderRefs"] == ["c-9"] + with pytest.raises(RequestTimeout): + go(Venue(placeOrders=timeout), lambda bf: bf.create_order(request())) + + def test_one_request_per_betfair_market(self): + def place(req): + body = json.loads(req.content) + report = S["place_lay"]["instructionReports"][0] + return {"status": "SUCCESS", "marketId": body["marketId"], + "instructionReports": [report for _ in body["instructions"]]} + + venue = Venue(placeOrders=place) + results = go(venue, lambda bf: bf.create_orders([ + request(), request(market_id="betfair:1.2-47972"), request(), request(post_only=True)])) + assert [type(r).__name__ for r in results] == ["Order", "Order", "Order", "InvalidOrder"] + bodies = venue.sent("placeOrders") + assert sorted((b["marketId"], len(b["instructions"])) for b in bodies) == [("1.109850906", 2), ("1.2", 1)] + assert all("customerRef" not in b for b in bodies) + assert results[1].market_id == "betfair:1.2-237486" # the report names the runner placed + + def test_the_italian_exchange_takes_fifty_a_request(self): + def place(req): + body = json.loads(req.content) + report = S["place_lay"]["instructionReports"][0] + return {"status": "SUCCESS", "instructionReports": [report for _ in body["instructions"]]} + + venue = Venue(placeOrders=place, getAccountDetails={"currencyCode": "EUR"}) + go(venue, lambda bf: bf.create_orders([request(quantity=Decimal("6")) for _ in range(60)]), jurisdiction="it") + assert [len(b["instructions"]) for b in venue.sent("placeOrders")] == [50, 10] + + def test_a_rejected_bet_is_typed(self): + venue = Venue(placeOrders=S["place_version_rejected"]) + with pytest.raises(OrderRejected) as caught: + go(venue, lambda bf: bf.create_order(request(params={"market_version": 4249587130}))) + assert caught.value.reason == "bet_taken_or_lapsed" + assert venue.sent("placeOrders")[0]["marketVersion"] == {"version": 4249587130} + + @pytest.mark.parametrize("code,error", [ + ("PERMISSION_DENIED", PermissionDenied), ("INSUFFICIENT_FUNDS", InsufficientFunds), + ("MARKET_SUSPENDED", MarketHalted), + ]) + def test_a_refused_request_is_typed(self, code, error): + refused = {"status": "FAILURE", "errorCode": code, "marketId": "1.109850906", + "instructionReports": [{"status": "FAILURE", "errorCode": "ERROR_IN_ORDER", + "instruction": S["place_lay"]["instructionReports"][0]["instruction"]}]} + with pytest.raises(error): + go(Venue(placeOrders=refused), lambda bf: bf.create_order(request())) + + def test_an_expired_session_logs_in_again_once(self): + answers = iter([httpx.Response(400, json=AUTH["invalid_session"]), httpx.Response(200, json=S["account_funds"])]) + venue = Venue(getAccountFunds=lambda req: next(answers)) + transport = httpx.MockTransport(venue) + session = BetfairSession(app_key="APP-KEY-LIVE", username="jbloggs", password="pw", + client=httpx.Client(transport=transport)) + balance = go(venue, lambda bf: bf.fetch_balance(), session=session) + assert len(venue.calls("login")) == 2 and balance.available == Decimal("95.5") + + def test_certificate_login_from_the_credentials(self, tmp_path): + venue = Venue(getAccountFunds=S["account_funds"]) + creds = BetfairCredentials(app_key="APP-KEY-LIVE", username="jbloggs", password="pw", + cert_path=str(tmp_path / "c.crt"), key_path=str(tmp_path / "c.key")) + transport = httpx.MockTransport(venue) + + async def body(): + async with BetfairTrading(creds, catalog=Catalog(), client=httpx.AsyncClient(transport=transport), + identity_client=httpx.Client(transport=transport)) as bf: + return await bf.fetch_balance() + + run(body()) + [login] = venue.calls("certlogin") + assert login.url.host == "identitysso-cert.betfair.com" + assert parse_qs(login.content.decode()) == {"username": ["jbloggs"], "password": ["pw"]} + assert venue.calls("getAccountFunds")[0].headers["X-Authentication"] == AUTH["certlogin_success"]["sessionToken"] + + +class TestManaging: + def test_cancel_finds_the_market_and_reads_the_bet_back(self): + after = current(status="EXECUTION_COMPLETE", sizeRemaining=0.0, sizeCancelled=5.0) + reads = iter([{"currentOrders": [current()], "moreAvailable": False}, + {"currentOrders": [after], "moreAvailable": False}]) + venue = Venue(cancelOrders=S["cancel"], listCurrentOrders=lambda req: next(reads)) + order = go(venue, lambda bf: bf.cancel_order(BET)) + assert venue.sent("cancelOrders") == [{"marketId": "1.117020524", "instructions": [{"betId": BET}]}] + assert order.status == OrderStatus.CANCELED + + def test_a_bet_already_taken_is_read_back(self): + matched = current(status="EXECUTION_COMPLETE", sizeMatched=5.0, sizeRemaining=0.0, averagePriceMatched=10.0) + venue = Venue(cancelOrders=S["cancel_taken"], listCurrentOrders={"currentOrders": [matched], "moreAvailable": False}) + order = go(venue, lambda bf: bf.cancel_order(BET, market_id=RUNNER)) + assert order.status == OrderStatus.CLOSED + + def test_cancel_all_everywhere_on_a_market_or_on_a_runner(self): + venue = Venue(cancelOrders=S["cancel"], listCurrentOrders={"currentOrders": [current()], "moreAvailable": False}) + + async def calls(bf): + return (await bf.cancel_all_orders(), await bf.cancel_all_orders(market_id="betfair:1.117020524"), + await bf.cancel_all_orders(market_id=RUNNER)) + + assert go(venue, calls) == (1, 1, 1) + everything, market, runner = venue.sent("cancelOrders") + assert everything == {} and market == {"marketId": "1.117020524"} + assert runner == {"marketId": "1.117020524", "instructions": [{"betId": BET}]} + + def test_a_new_price_is_a_replaced_bet(self): + venue = Venue(replaceOrders=S["replace"]) + order = go(venue, lambda bf: bf.edit_order(EditRequest(order_id=BET, price=Decimal("1") / 12), + current=order_of_current(current()))) + assert venue.sent("replaceOrders") == [{"marketId": "1.117020524", + "instructions": [{"betId": BET, "newPrice": 12}]}] + assert order.id == "45496907999" and order.price == (1 / Decimal(12)).quantize(Decimal("1e-10")) + assert order.queue_priority_preserved is False and order.info["replaced"] == BET + + def test_a_smaller_quantity_is_a_partial_cancel(self): + after = current(sizeRemaining=3.0, sizeCancelled=2.0) + venue = Venue(cancelOrders=S["cancel_partial"], listCurrentOrders={"currentOrders": [after], "moreAvailable": False}) + order = go(venue, lambda bf: bf.edit_order(EditRequest(order_id=BET, quantity=Decimal("30")), + current=order_of_current(current()))) + assert venue.sent("cancelOrders") == [{"marketId": "1.117020524", + "instructions": [{"betId": BET, "sizeReduction": 2}]}] + assert order.remaining == Decimal("30.0") and order.queue_priority_preserved is True + + def test_a_stake_cannot_be_raised(self): + with pytest.raises(InvalidOrder, match="raise"): + go(Venue(), lambda bf: bf.edit_order(EditRequest(order_id=BET, quantity=Decimal("80")), + current=order_of_current(current()))) + + def test_persistence_is_updated_in_place(self): + kept = current(persistenceType="PERSIST") + venue = Venue(updateOrders=S["update"], listCurrentOrders={"currentOrders": [kept], "moreAvailable": False}) + order = go(venue, lambda bf: bf.update_persistence(BET, "persist", market_id=RUNNER)) + assert venue.sent("updateOrders") == [{"marketId": "1.117020524", + "instructions": [{"betId": BET, "newPersistenceType": "PERSIST"}]}] + assert order.info["persistenceType"] == "PERSIST" + + def test_a_bet_gone_from_the_current_orders_is_found_among_the_cleared(self): + def cleared(req): + status = json.loads(req.content)["betStatus"] + return S["cleared_cancelled"] if status == "CANCELLED" else {"clearedOrders": [], "moreAvailable": False} + + venue = Venue(listCurrentOrders={"currentOrders": [], "moreAvailable": False}, listClearedOrders=cleared) + order = go(venue, lambda bf: bf.fetch_order(BET)) + assert order.status == OrderStatus.CANCELED and order.market_id == RUNNER + assert [b["betStatus"] for b in venue.sent("listClearedOrders")] == ["CANCELLED"] + + def test_open_orders_on_a_runner_page_through(self): + pages = iter([{"currentOrders": [current()], "moreAvailable": True}, + {"currentOrders": [current(betId="2", selectionId=1)], "moreAvailable": False}]) + venue = Venue(listCurrentOrders=lambda req: next(pages)) + orders = go(venue, lambda bf: bf.fetch_open_orders(market_id=RUNNER)) + assert [o.id for o in orders] == [BET] + first, second = venue.sent("listCurrentOrders") + assert first["orderProjection"] == "EXECUTABLE" and first["marketIds"] == ["1.117020524"] + assert second["fromRecord"] == 1 + + def test_fetch_orders_pages_by_offset(self): + venue = Venue(listCurrentOrders={"currentOrders": [current()], "moreAvailable": True}) + page = go(venue, lambda bf: bf.fetch_orders(status="open", limit=1, since=1421931713000)) + assert page.next_cursor == "1" and len(page) == 1 + [sent] = venue.sent("listCurrentOrders") + assert sent["recordCount"] == 1 and sent["dateRange"] == {"from": "2015-01-22T13:01:53Z"} + + def test_my_trades_are_the_bets_that_stopped_matching(self): + rows = [current(status="EXECUTION_COMPLETE", sizeMatched=5.0, sizeRemaining=0.0, averagePriceMatched=10.0, + matchedDate="2015-01-22T13:02:00.000Z"), + current(betId="2", sizeMatched=1.0, averagePriceMatched=10.0)] + venue = Venue(listCurrentOrders={"currentOrders": rows, "moreAvailable": False}) + fills = go(venue, lambda bf: bf.fetch_my_trades()) + assert [f.id for f in fills] == [BET] and fills[0].quantity == Decimal("50.0") + assert venue.sent("listCurrentOrders")[0]["orderBy"] == "BY_MATCH_TIME" + + def test_balance_positions_profit_and_loss_and_settlements(self): + venue = Venue( + getAccountFunds=S["account_funds"], listMarketProfitAndLoss=S["profit_and_loss"], + listCurrentOrders={"currentOrders": [current(sizeMatched=2.0, averagePriceMatched=12.0)], "moreAvailable": False}, + listClearedOrders=S["cleared_orders"], + ) + + async def calls(bf): + return (await bf.fetch_balance(), await bf.fetch_positions(market_id=RUNNER), + await bf.fetch_profit_and_loss([RUNNER]), await bf.fetch_settlements()) + + balance, positions, pnl, settlements = go(venue, calls) + assert (balance.total, balance.available, balance.locked, balance.currency) == ( + Decimal("100.0"), Decimal("95.5"), Decimal("4.5"), "GBP") + assert positions[0].contracts == Decimal("24.0") + assert pnl[0]["profitAndLosses"][0]["ifWin"] == 45.0 + assert venue.sent("listMarketProfitAndLoss")[0]["marketIds"] == ["1.117020524"] + # The documented report mixes a market roll-up in; only bets naming a runner settle one. + assert len(settlements) == 6 and venue.sent("listClearedOrders")[0]["betStatus"] == "SETTLED" + + def test_the_fee_is_commission_on_what_the_bet_wins(self): + async def calls(bf): + return (await bf.fetch_fee_estimate(RUNNER, Side.BUY, Decimal("0.4"), Decimal("100")), + await bf.fetch_fee_estimate(RUNNER, Side.SELL, Decimal("0.4"), Decimal("100"))) + + buy, sell = go(Venue(), calls) + assert buy.taker_fee == Decimal("3.0") and sell.taker_fee == Decimal("2.0") and buy.maker_fee == buy.taker_fee + assert buy.currency == "GBP" + + +class TestWiring: + def test_credentials_are_needed(self): + with pytest.raises(CredentialsMissing): + BetfairTrading(None) + + def test_the_client_the_engine_and_serve_build_it(self): + import synpath + from synpath.engine.__main__ import ADAPTERS + from synpath.server.serve import VENUES + + creds = BetfairCredentials(app_key="k", username="u", password="p") + trading = synpath.Client({"betfair": creds}).trading("betfair") + try: + assert isinstance(trading, BetfairTrading) and trading.has["edit_order"] is True + finally: + run(trading.close()) + assert ADAPTERS["betfair"] == ("..trading.betfair", "BetfairTrading") and "betfair" in VENUES + assert synpath.BetfairTrading is BetfairTrading + + def test_the_engine_streams_share_the_adapters_session(self): + from synpath.engine.feeds import _trading_streams + from synpath.ws.betfair import BetfairMarketStream, BetfairUserStream + + creds = BetfairCredentials(app_key="k", username="u", password="p") + trading = BetfairTrading(creds, catalog=Catalog()) + try: + streams = _trading_streams({"betfair": trading}, {"betfair": creds})["betfair"] + assert isinstance(streams.market, BetfairMarketStream) and isinstance(streams.private, BetfairUserStream) + assert streams.private.session is trading.session is streams.market.session + finally: + run(trading.close()) + + def test_doctor_names_the_login(self, tmp_path, capsys): + from synpath.trading.__main__ import doctor + + env = tmp_path / ".env" + env.write_text("BETFAIR_APP_KEY=app-key-secret-1\nBETFAIR_USERNAME=jbloggs\nBETFAIR_PASSWORD=secret-pw-123\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + assert "user jbloggs" in out and "live key" in out + assert "secret-pw-123" not in out and "app-key-secret-1" not in out diff --git a/tests/test_betfair_ws.py b/tests/test_betfair_ws.py index 4cf7b67..d331a56 100644 --- a/tests/test_betfair_ws.py +++ b/tests/test_betfair_ws.py @@ -1,8 +1,11 @@ """Betfair's Exchange Stream API, spoken against a fake TLS socket. -The messages are hand-built to the shapes in Betfair's Exchange Stream API -documentation (and betfairlightweight's streaming fixtures); nothing here -opens a connection to Betfair. +The market messages are hand-built to the shapes in Betfair's Exchange +Stream API documentation (and betfairlightweight's streaming fixtures). The +order change messages are the documentation's own examples (a market-level +snapshot, a reconnection image, a runner removal, a VAR void), the VAR +example's bare runner changes wrapped in an `ocm`. Nothing here opens a +connection to Betfair. """ from __future__ import annotations @@ -13,9 +16,12 @@ import pytest -from synpath import AuthenticationError, BetfairMarketStream +from synpath import AuthenticationError, BetfairMarketStream, BetfairUserStream from synpath.betfair import BetfairSession -from synpath.ws.base import BookEvent, MarketStatusEvent, QuoteEvent, StreamStatusEvent +from synpath.trading.types import OrderStatus, PositionSide, Side +from synpath.ws.base import ( + BookEvent, FillEvent, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, StreamStatusEvent, +) from synpath.ws.betfair import ( DELAYED_CONFLATE_MS, MAX_MARKETS, LineSocket, apply_ladder, changed, yes_levels, yes_price, ) @@ -355,11 +361,145 @@ async def test_betfairs_own_refusal_is_reported(self): class TestFeeds: - def test_the_engine_gets_a_market_stream_and_no_account_stream(self): + def test_the_engine_gets_a_market_stream_and_an_account_stream(self): from synpath.engine.feeds import default_streams from synpath.trading.credentials import BetfairCredentials creds = BetfairCredentials(app_key="k", username="u", password="p") streams = default_streams({"betfair": object()}, {"betfair": creds}) - assert isinstance(streams["betfair"].market, BetfairMarketStream) and streams["betfair"].private is None + assert isinstance(streams["betfair"].market, BetfairMarketStream) + assert isinstance(streams["betfair"].private, BetfairUserStream) assert default_streams({"betfair": object()}, {}) == {} + + +ORDERS = load("betfair_trading_stream.json") +REMOVAL_RUNNER = "betfair:1.102151675-6113662" + + +def order_message(name: str, **changes) -> str: + return json.dumps({**ORDERS[name], **changes}) + + +async def order_stream(*kinds, **kwargs) -> BetfairUserStream: + stream = BetfairUserStream(session=a_session(), **kwargs) + for kind in kinds or ("orders", "fills", "positions"): + await getattr(stream, {"orders": "watch_orders", "fills": "watch_my_trades", + "positions": "watch_positions"}[kind])() + await authenticated(stream) + stream._dispatch(order_message("subscribe_success", id=stream._sub_id)) + drain(stream) + return stream + + +class TestOrderStream: + async def test_the_order_subscription_follows_authentication(self): + stream = await order_stream(strategy_refs=["betstrategy1"]) + subscription = stream._ws.sent[-1] + documented = ORDERS["subscription_example"] + assert subscription["op"] == documented["op"] and subscription["segmentationEnabled"] is True + assert subscription["orderFilter"] == {"includeOverallPosition": True, "customerStrategyRefs": ["betstrategy1"]} + assert "clk" not in subscription + + async def test_nothing_is_subscribed_until_something_is_watched(self): + stream = BetfairUserStream(session=a_session()) + await authenticated(stream) + assert [frame["op"] for frame in stream._ws.sent] == ["authentication"] + + async def test_a_snapshot_reports_the_resting_bet_without_a_fill(self): + stream = await order_stream() + stream._dispatch(order_message("market_snapshot")) + events = drain(stream) + [event] = [e for e in events if isinstance(e, OrderEvent)] + order = event.order + assert order.id == "215144775671" and order.market_id == "betfair:1.174743281-30246" + assert order.side == Side.BUY and order.status == OrderStatus.OPEN and order.price == yes_price(990) + assert order.quantity == Decimal("1980") and order.created_at == 1603894536000 + assert not [e for e in events if isinstance(e, FillEvent)] + + async def test_a_bet_matching_is_a_fill_then_a_runner_removal_is_not(self): + stream = await order_stream() + stream._dispatch(order_message("rule4_placed")) + placed = drain(stream) + assert [type(e).__name__ for e in placed] == ["OrderEvent", "PositionEvent"] + assert placed[0].order.status == OrderStatus.OPEN and placed[1].position.contracts == 0 + + stream._dispatch(order_message("rule4_matched")) + fill, order, position = drain(stream) + assert isinstance(fill, FillEvent) and fill.fill.id == "10822867886:2" + assert fill.fill.quantity == Decimal("24") and fill.fill.price == yes_price(12) + assert order.order.status == OrderStatus.CLOSED and order.order.filled == Decimal("24") + assert order.order.last_fill_quantity == Decimal("24") + assert position.position.market_id == REMOVAL_RUNNER and position.position.inventory_yes == Decimal("24") + assert position.position.side == PositionSide.LONG + + # A runner removed: the matched price is reduced to 9.47, nothing new matched. + stream._dispatch(order_message("rule4_reduced")) + events = drain(stream) + assert not [e for e in events if isinstance(e, FillEvent)] + [position] = [e.position for e in events if isinstance(e, PositionEvent)] + assert position.inventory_yes == Decimal("2") * Decimal("9.47") + assert position.info["matched_backs"] == [["9.47", "2"]] + + async def test_a_reconnection_image_is_the_baseline(self): + stream = await order_stream() + stream._dispatch(order_message("reconnect_image")) + events = drain(stream) + assert not [e for e in events if isinstance(e, FillEvent)] + [order] = [e.order for e in events if isinstance(e, OrderEvent)] + assert order.filled == Decimal("4.75") * 12 and order.remaining == Decimal("0.25") * 12 + positions = {e.position.market_id: e.position for e in events if isinstance(e, PositionEvent)} + assert positions["betfair:1.125657695-48756"].inventory_yes == Decimal("2.8") + assert stream.initial_clk == ORDERS["reconnect_image"]["initialClk"] + + # The rest matched while disconnected: the next image has the bet gone and the position whole. + stream._dispatch(order_message("reconnect_image_matched")) + events = drain(stream) + positions = {e.position.market_id: e.position for e in events if isinstance(e, PositionEvent)} + assert positions["betfair:1.125657760-151478"].inventory_yes == Decimal("60") + assert positions["betfair:1.125670254-5643663"].contracts == 0 + assert stream.orders == {} + + async def test_a_reconnect_resumes_from_the_clock(self): + stream = await order_stream() + stream._dispatch(order_message("reconnect_image")) + await authenticated(stream) + resubscribe = stream._ws.sent[-1] + assert resubscribe["initialClk"] == ORDERS["reconnect_image"]["initialClk"] + assert resubscribe["clk"] == ORDERS["reconnect_image"]["clk"] + + async def test_a_var_void_and_a_lapse_end_bets_without_fills(self): + stream = await order_stream() + stream._dispatch(order_message("var_lay_matched")) + events = drain(stream) + status = [e for e in events if isinstance(e, StreamStatusEvent)] + assert status[0].state == "error" and status[0].reconcile_required and "503" in status[0].detail + [fill] = [e.fill for e in events if isinstance(e, FillEvent)] + assert fill.side == Side.SELL and fill.quantity == Decimal("4") and fill.price == Decimal("0.5") + [position] = [e.position for e in events if isinstance(e, PositionEvent)] + assert position.side == PositionSide.SHORT and position.inventory_no == Decimal("8") + + stream._dispatch(order_message("var_back_placed")) + drain(stream) + stream._dispatch(order_message("var_lay_voided")) + stream._dispatch(order_message("var_back_lapsed")) + 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)] == ["resynced"] + voided, lapsed = [e.order for e in events if isinstance(e, OrderEvent)] + assert voided.status == OrderStatus.CANCELED and voided.filled == 0 and voided.info["sizeVoided"] == 2 + assert lapsed.status == OrderStatus.CANCELED and lapsed.info["sizeLapsed"] == 2 and lapsed.remaining == 0 + + async def test_only_the_markets_watched_are_passed_on(self): + stream = BetfairUserStream(session=a_session()) + await stream.watch_orders(["betfair:1.102151675"]) + await authenticated(stream) + stream._dispatch(order_message("market_snapshot")) + stream._dispatch(order_message("rule4_placed")) + orders = [e.order for e in drain(stream) if isinstance(e, OrderEvent)] + assert [o.market_id for o in orders] == [REMOVAL_RUNNER] + + async def test_a_heartbeat_carries_the_streams_health(self): + stream = await order_stream() + stream._dispatch(order_message("heartbeat_503")) + [status] = drain(stream) + assert status.state == "error" and stream.clk == "AAAAAAAA" From 5a78ca85e6a21626bfbb0a62f59a0e9fc6299a71 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 13/19] feat: smarkets-trading Co-Authored-By: Claude Opus 5.5 --- .env.example | 11 +- README.md | 4 +- docs/api.md | 2 + docs/capabilities.md | 33 +- docs/streaming.md | 8 +- docs/trading.md | 88 ++- synpath/__init__.py | 4 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 10 +- synpath/server/serve.py | 2 +- synpath/smarkets.py | 4 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 8 +- synpath/trading/credentials.py | 14 +- synpath/trading/init.py | 8 +- synpath/trading/instruments.py | 10 +- synpath/trading/smarkets.py | 921 ++++++++++++++++++++++++++++ tests/samples/smarkets_trading.json | 235 +++++++ tests/test_smarkets.py | 6 +- tests/test_smarkets_trading.py | 528 ++++++++++++++++ 21 files changed, 1848 insertions(+), 60 deletions(-) create mode 100644 synpath/trading/smarkets.py create mode 100644 tests/samples/smarkets_trading.json create mode 100644 tests/test_smarkets_trading.py diff --git a/.env.example b/.env.example index 207b78e..e0737a3 100644 --- a/.env.example +++ b/.env.example @@ -106,11 +106,12 @@ BETFAIR_CERT_PATH= BETFAIR_KEY_PATH= BETFAIR_JURISDICTION= -# Smarkets (market data and a polled stream; no order entry yet): market data -# needs no login at all. A login (optional) sends a session token for -# undelayed quotes and your account's own rate limits. Betting through the API -# needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API terms -# allow viewing prices and betting on your own account; odds scraping is +# Smarkets (market data, a polled stream, and order entry): market data needs +# no login at all. A login sends a session token for undelayed quotes, your +# account's own rate limits, and its orders, balance and positions; an account +# with two-factor login cannot log in from here. Placing orders through the +# API needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API +# terms allow viewing prices and betting on your own account; odds scraping is # prohibited. SMARKETS_USERNAME= SMARKETS_PASSWORD= diff --git a/README.md b/README.md index 40c5d11..d86b91f 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Gemini Predictions](https://www.gemini.com/predictions) | `gemini` | ✓ no account, sports taxonomy | ✓ US accounts with Prediction Markets access | WebSocket (no key for books) | one book per contract, in YES space | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | -| | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | +| | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | ✓ your own login, with Smarkets' approved API access | polled from REST quotes | one book per contract, bids are YES bids | | | [Novig](https://novig.com) | `novig` | ✓ sports, no key | ✓ (US, identity and location checks; Paper for play money) | WebSocket (trading key), else polled from the public book | one book per market, both sides read it | | | [Matchbook](https://www.matchbook.com) | `matchbook` | ✓ no account | ✓ your own login | polled from REST | one book per runner, back and lay read as YES | | | [ProphetX](https://www.prophetx.co) | `prophetx` | ✓ sports (Trading API key pair, or a Market Data API key) | ✓ | WebSocket (Trading API key pair) | one book per line, both sides read it | @@ -93,7 +93,7 @@ Betfair is a betting exchange, not a prediction market, read here the same way: Gemini Predictions is Gemini Titan's CFTC-registered event-contract exchange. Each contract is a yes/no market named by its instrument symbol, `gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`, and the Gemini event holding the contracts is the event. Sports events carry Gemini's taxonomy (moneyline, spread, total, prop; the sport, scope and statistic), which listings filter on. Market data needs no account; trading needs a Gemini account with Prediction Markets access (US) and an API key. See [capabilities](docs/capabilities.md#gemini). -Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). +Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting logs in with your own Smarkets username and password, and placing orders needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). Novig, a US sports exchange, lists two-outcome markets (a moneyline, a spread, a total, a Yes/No prop), each one yes/no market, `novig:01a1577c-d666-7e81-852e-68bed1446801`, under its game as the event. Its catalog, books and trades are public and need no key. Trading signs each request with a subaccount's key; Production trades real money for verified US customers outside Arizona, Michigan and Nevada, and Paper (`NOVIG_ENV=paper`) is the same API with play money. Novig's parlay RFQ, for registered liquidity providers, is not supported yet. See [capabilities](docs/capabilities.md#novig). diff --git a/docs/api.md b/docs/api.md index 4de4820..97402ea 100644 --- a/docs/api.md +++ b/docs/api.md @@ -113,6 +113,8 @@ with its contracts, open or settled), `fetch_smarkets_events(ids)`, market id, and takes `domain` (`"football"`, `"politics"`), `scope` (`"single_event"`, `"outright"`) and `market_type` (`"WINNER_3_WAY"`) on `fetch_markets` and `fetch_events`. See [Smarkets](capabilities.md#smarkets). +Order entry is `SmarketsTrading`, with the same login; see +[trading](trading.md#smarkets). ```python smk = synpath.Smarkets() # no account; SMARKETS_USERNAME / _PASSWORD undelay the quotes diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..2d635f7 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -170,8 +170,9 @@ per 60 s (50 on `/quotes/`) for an API account. Every book and market says `SMARKETS_PASSWORD`) sends a session token (`Authorization: Session-Token`) for undelayed quotes; it lasts 30 minutes from its last use, and Smarkets allows 5 logins per 5 minutes. Accounts with two-factor login are refused with -a clear error. **Betting needs Smarkets' approved API access** (applied for, -with a one-off £150 fee); there is no Smarkets order entry here yet. +a clear error. The same login places and cancels orders with +`SmarketsTrading` (see [trading](trading.md#smarkets)); **placing orders needs +Smarkets' approved API access** (applied for, with a one-off £150 fee). Requests carry a `synpath/` User-Agent: Smarkets' Cloudflare front answers the default ones of HTTP libraries (python-httpx, curl) with a 403 @@ -527,20 +528,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `smarkets` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | one at a time; `market` and `ioc` are the venue's `immediate_or_cancel` at the worst price given, `fok` the same with the whole order as its minimum; limits rest `good_til_halted` (or `keep_in_play`); no `gtd` or post-only; placing needs Smarkets' approved API access | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | one at a time; `cancel_all_orders` per Smarkets market or account-wide, once a minute, and otherwise (or per contract) lists then cancels | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while Smarkets lists it (current and recently settled orders) | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: current and recently settled orders, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | yes, the statement's executions | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, matched unsettled orders netted per contract; the account's balance, exposure and available balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | partial: recently settled orders, before commission | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, the account's commission on what the bet wins, makers and takers alike | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | +| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..62e403e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -79,7 +79,7 @@ variable `synpath serve` reads it from: | predict.fun | the API key (`pred_sk_...`, from developers.predict.fun); mainnet only | `PREDICT_FUN_API_KEY` | | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | | Betfair | the application key and a login (every Betfair call needs one) | `BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD` | -| Smarkets | nothing; an optional login undelays the quotes | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | +| Smarkets | nothing; a login undelays the quotes (the same one `SmarketsTrading` uses) | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | | Novig | the WebSocket: a subaccount's trading key (key id and private key file; the handshake is signed). Without one the public book is polled | `NOVIG_KEY_ID`, `NOVIG_PRIVATE_KEY_PATH`, `NOVIG_ENV` | | Matchbook | nothing | | | ProphetX | the Trading API key pair (prophetx.co -> API Integration); the Market Data API key has no stream | `PROPHETX_ACCESS_KEY`, `PROPHETX_SECRET_KEY` | @@ -246,7 +246,11 @@ jittered exponential backoff between attempts. reports it `resynced`. Quotes are delayed without a login, and every event says so in `info["delayed"]`. A change shows at the next poll, not as it happens, so its `watch_*` keys are `partial`. No `watch_trades`: the recent - tape is short and unpaged; read it with `Smarkets.fetch_trades`. + tape is short and unpaged; read it with `Smarkets.fetch_trades`. There is + no account stream either (Smarkets' trading API is HTTP only), so the + engine polls `SmarketsTrading` for orders and fills; under the engine the + market stream polls on the trading adapter's session, so its quotes are + undelayed. - *Novig* carries every channel on one WebSocket, `wss://api.novig.com/v3/ws` (Paper: `wss://api.paper.novig.com/v3/ws`), opened with a subaccount's `trading` or `trading::read` key: the upgrade is signed like any request diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..15266d0 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -72,6 +72,7 @@ private key. `synpath init` tells them apart. | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | | Novig | REST: nothing. Books over the WebSocket: a subaccount's trading key (without one, the public book is polled) | a subaccount's trading key: a key id and its private key file (PEM, Ed25519 or P-256). The management key, optional, opens, funds and labels subaccounts and never trades | the management key on novig.com (paper.novig.com for Paper) -> Profile -> Settings -> Novig API; then `NovigTrading.open_subaccount` with a keypair from `novig_signing.generate_key()` issues the trading key. The private key is never sent | +| Smarkets | nothing (a login undelays the quotes) | the account's login: username and password (an account with two-factor login passes a session token made elsewhere); placing orders needs Smarkets' approved API access | your smarkets.com account; API access is applied for from Smarkets (a one-off £150 fee) | | Matchbook | nothing | the account's login: username and password (an account with two-factor login passes a session token made elsewhere) | your matchbook.com account; higher API limits need an API plan from api@matchbook.com | | ProphetX | the Trading API key pair, or a Market Data API key | the Trading API key pair: an access key and a secret key, exchanged for a ten-minute bearer token | prophetx.co -> Menu -> API Integration, on any KYC-approved account (sandbox.prophetx.dev for the sandbox); the secret is shown once. The Market Data API key is issued by ProphetX on request | | Azuro | nothing | the private key of the wallet that signs each bet and holds the bet token, which must allow Azuro's relayer to spend it (`approve()`, once) | exported from the wallet | @@ -103,6 +104,7 @@ The variables each venue reads: | Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | +| Smarkets | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` (betting and the account's orders, statement and balance; market data needs neither, and with them its quotes are undelayed) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | | ProphetX | `PROPHETX_ACCESS_KEY` and `PROPHETX_SECRET_KEY` (the Trading API: market data, streams, orders), `PROPHETX_AFFILIATE_KEY` (optional; the Market Data API, market data only), `PROPHETX_SANDBOX=1` (optional; ProphetX's sandbox, with demo funds) | | Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance and allowance are read from and the approval is sent through; a public one when unset) | @@ -115,10 +117,9 @@ and the stream only: there is no Betfair order entry yet. See [Betfair](capabilities.md#betfair). Smarkets needs no credentials for market data. `SMARKETS_USERNAME` and -`SMARKETS_PASSWORD` are optional: they log in for undelayed quotes, and -`synpath doctor` reports them. There is no Smarkets order entry yet: betting -through Smarkets' API needs its approved API access (applied for, with a -one-off £150 fee). See [Smarkets](capabilities.md#smarkets). +`SMARKETS_PASSWORD` log in for undelayed quotes and for order entry; placing +orders through Smarkets' API needs its approved API access (applied for, with +a one-off £150 fee). See [Smarkets](#smarkets). Novig trades on one subaccount per trading key. An order buys an outcome: `buy` the market's YES outcome at the price given, `sell` its NO outcome at @@ -613,6 +614,85 @@ free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. +## Smarkets + +`SmarketsTrading`, built to Smarkets' trading API reference +(docs.smarkets.com, API v3). Every call carries the session token of an +account login (`SMARKETS_USERNAME`, `SMARKETS_PASSWORD`), made on the first +call and kept in memory only, as `Authorization: Session-Token `. A +token lasts 30 minutes from its last use: one idle for 25 is replaced before +the next call, and a 401 logs in again once. Smarkets allows five logins per +five minutes. An account with two-factor login cannot log in from here; pass +`session_token=` from a login made elsewhere. **Placing orders needs +Smarkets' approved API access** (applied for, with a one-off £150 fee): any +account reads its balance, orders and statement, and an account without it +is refused a new order with `PermissionDenied`. + +An order buys or sells one contract's YES, `smarkets:-`. `buy` backs the contract (takes its YES), `sell` lays it (takes its +NO), and the price is the YES price, as Smarkets' book reads: its `bids` are +resting backs, YES bids. Smarkets prices in basis points of 100%, and only on +its odds ladder (decimal odds 1.01 to 1000 in Betfair's bands, as basis +points, and the ends 1 and 9999). A price within half a basis point of a tick +is that tick; any other is snapped onto the ladder on the cheaper side: a buy +at the tick below, a sell at the tick above. An order's size is its pot (back +stake plus lay liability) in 1/10,000 of the account's currency, so `n` +contracts send `n * 10,000`, rounded down; the smallest back stake is £0.05. + +A limit is `good_til_halted` by default, Smarkets' own default: it rests +through the market going in play and is cancelled when the market halts (a +goal, a red card). `params={"order_type": "keep_in_play"}` (or +`{"keep_in_play": True}`) keeps it through halts as well, and +`"good_til_cancelled"` cancels it when the market goes in play. Smarkets +cancels every resting order when it applies a reduction factor (a withdrawn +runner). `market` and `ioc` are `immediate_or_cancel` at the price given as +the worst accepted, answered once they have matched what they could; `fok` is +the same with `minimum_accepted_quantity` set to the whole order, and +`params={"minimum_quantity": n}` sets a smaller floor on an `ioc`. One that +cannot meet its floor is refused (`OrderRejected`, +`ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY`). `gtd`, `day`, post-only and +`reduce_only` are refused. There is no amend route, so `edit_order` is not +offered; cancel and replace. `create_orders` and `cancel_orders` send one +request per order and return one result each. + +Smarkets takes no client order id, but every order carries a free `label`: +`client_order_id` (or `params["label"]`) is sent as the label and read back +as `client_order_id`. After a timeout, list the open orders on the contract +(`fetch_open_orders(market_id=...)`) and match the label to see what was +placed. + +Orders are read from `GET /v3/orders/`, which lists current and recently +settled orders: `fetch_order`, `fetch_open_orders`, `fetch_orders` (by +status, oldest first) and `fetch_settlements` (settled orders, one per +contract and side, with `payout` and `pnl` before commission). Smarkets has +no cancelled state: an order with nothing left unmatched that did not match +in full is `canceled`. `cancel_order` reads the order back; one the exchange +has not finished cancelling reads `pending_cancel`. `cancel_all_orders` +cancels the account's orders, or a Smarkets market's +(`smarkets:`), in one request that Smarkets acknowledges without +a count, once a minute; a second inside the minute, or one naming a single +contract, cancels the open orders one by one and returns how many. In play, +matched bets wait about eight seconds for confirmation and can be voided in +that time; the pending part counts as filled (`info["quantity_filled_pending"]`). + +Fills are the account statement's executions (`order.execute` rows of +`/v3/accounts/activity/`), newest first. Positions net the account's matched, +unsettled orders per contract on the YES leg: backs are YES contracts, lays +NO contracts, and `margin` is the most the contract can lose. The balance is +the account's cash, what its bets hold (`exposure`, `locked`), and what is +free to bet, in the account's currency. Commission is charged at settlement +on net winnings per Smarkets market, 2% on the standard tier; fee estimates +use the account's own rate (`commission_type`). + +Smarkets allows 1,200 requests a minute per account, ten order placements +and ten cancels a second, and one cancel-all a minute; the adapter paces +itself inside them. Smarkets' trading API is HTTP only (its socket feed is +for approved market makers), so there is no account stream and the engine +polls the orders. + +Check Smarkets' terms for where it may be used before trading: its API terms +allow viewing prices and betting on your own account only. + ## Matchbook `MatchbookTrading`, built to Matchbook's API reference diff --git a/synpath/__init__.py b/synpath/__init__.py index ff88c20..d5484fa 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -127,7 +127,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, MatchbookTrading, ProphetXTrading, AzuroTrading, MyriadTrading, GeminiTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, SmarketsTrading, MatchbookTrading, ProphetXTrading, AzuroTrading, MyriadTrading, GeminiTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -196,7 +196,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "SmarketsTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index a19ec85..d3f5a33 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -388,6 +388,11 @@ def _novig(creds: Credentials) -> TradingExchange: return NovigTrading(creds) # type: ignore[arg-type] +def _smarkets(creds: Credentials) -> TradingExchange: + from .trading.smarkets import SmarketsTrading + return SmarketsTrading(creds) # type: ignore[arg-type] + + def _matchbook(creds: Credentials) -> TradingExchange: from .trading.matchbook import MatchbookTrading return MatchbookTrading(creds) # type: ignore[arg-type] @@ -417,6 +422,7 @@ def _gemini(creds: Credentials) -> TradingExchange: "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, "novig": _novig, + "smarkets": _smarkets, "matchbook": _matchbook, "prophetx": _prophetx, "azuro": _azuro, diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index 352385a..82f5755 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -66,6 +66,7 @@ "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), "novig": ("..trading.novig", "NovigTrading"), + "smarkets": ("..trading.smarkets", "SmarketsTrading"), "matchbook": ("..trading.matchbook", "MatchbookTrading"), "prophetx": ("..trading.prophetx", "ProphetXTrading"), "azuro": ("..trading.azuro", "AzuroTrading"), diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index a4672bf..b86aa57 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -182,11 +182,13 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] private=GeminiUserStream(creds) if creds is not None else None, ) elif venue == "smarkets": - # Market data only, and public: a polled stream on REST quotes - # (Smarkets' socket stream is for approved market makers). The - # login, when configured, only undelays the quotes. + # A polled stream on REST quotes (Smarkets' socket stream is for + # approved market makers), on the trading adapter's session when + # there is one, so the login undelays the quotes. No account + # stream; the engine polls the orders. from ..ws.smarkets import SmarketsMarketStream - out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) + session = getattr(adapter, "session", None) + out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds, session=session), private=None) elif venue == "novig": # The websocket takes a subaccount's trading key; without one the # market stream polls the public book. diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 462b90e..bd13ee2 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -36,7 +36,7 @@ log = logging.getLogger("synpath.serve") VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "novig", - "matchbook", "prophetx", "azuro", "myriad", "gemini") + "smarkets", "matchbook", "prophetx", "azuro", "myriad", "gemini") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/smarkets.py b/synpath/smarkets.py index d1698f9..63c616f 100644 --- a/synpath/smarkets.py +++ b/synpath/smarkets.py @@ -7,8 +7,8 @@ records which in its `info` (`delayed`). An optional login (`SMARKETS_USERNAME`, `SMARKETS_PASSWORD`) sends a session token for undelayed quotes and the account's own limits; see `SmarketsSession`. -Betting needs approved API access from Smarkets (a one-off £150 fee) and is -not offered here yet. +Order entry is `synpath.trading.smarkets.SmarketsTrading`, on the same login; +placing orders needs approved API access from Smarkets (a one-off £150 fee). Five things about this venue that shape the adapter: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index a7a2365..a052864 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -84,6 +84,9 @@ def __getattr__(name: str): if name == "NovigTrading": from .novig import NovigTrading return NovigTrading + if name == "SmarketsTrading": + from .smarkets import SmarketsTrading + return SmarketsTrading if name == "MatchbookTrading": from .matchbook import MatchbookTrading return MatchbookTrading @@ -105,7 +108,7 @@ def __getattr__(name: str): __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", - "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "SmarketsTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index 1746aae..5b0de07 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -61,9 +61,9 @@ def doctor(dotenv: str | None) -> int: for venue, creds in loaded.items(): if creds is None and venue == "smarkets": # Optional: Smarkets serves its market data without a login. - rows.append((venue, "no login (optional)", "market data needs none; set SMARKETS_USERNAME / " - "SMARKETS_PASSWORD for undelayed quotes, or run synpath init. Betting needs Smarkets' " - "approved API access (£150 one-off), not supported yet")) + rows.append((venue, "not configured", "market data needs no login; set SMARKETS_USERNAME / " + "SMARKETS_PASSWORD to bet and for undelayed quotes, or run synpath init. Placing orders " + "needs Smarkets' approved API access")) continue if creds is None and venue == "matchbook": # Market data is public; the login is for betting. @@ -144,7 +144,7 @@ def doctor(dotenv: str | None) -> int: identity = f"api key {creds.api_key[:12]}…{sub} (account-scoped, time-based nonce for streams)" elif venue == "smarkets": stage = "login" - identity = f"user {creds.username}: undelayed quotes; betting needs approved API access, not supported yet" + identity = f"user {creds.username}: undelayed quotes and the account; placing orders needs approved API access" elif venue == "matchbook": stage = "login" identity = f"user {creds.username}" diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..1b47c31 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -48,8 +48,10 @@ Betfair BETFAIR_APP_KEY, BETFAIR_USERNAME, BETFAIR_PASSWORD, and (market data) optionally BETFAIR_CERT_PATH / BETFAIR_KEY_PATH (certificate login) and BETFAIR_JURISDICTION - Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market - (market data) data needs no login; one gives undelayed quotes + Smarkets SMARKETS_USERNAME, SMARKETS_PASSWORD: betting and the + account log in (placing orders needs Smarkets' approved + API access); market data needs no login, and one gives + undelayed quotes Novig NOVIG_KEY_ID, NOVIG_PRIVATE_KEY_PATH (a subaccount's trading key, PEM, Ed25519 or P-256), NOVIG_ENV (production, the default, or paper), and optionally NOVIG_MANAGEMENT_KEY_ID / @@ -403,9 +405,11 @@ def secrets(self) -> list[str]: @dataclass(frozen=True, repr=False) class SmarketsCredentials: - """Smarkets: an optional account login. Market data needs none; a session - from it gives undelayed quotes and the account's own rate limits. Betting - needs Smarkets' approved API access, which this library does not use yet.""" + """Smarkets: the account's login. Betting and the account's reads (orders, + statement, balance) log in with it; market data needs none, and a session + from it gives undelayed quotes and the account's own rate limits. Placing + orders needs Smarkets' approved API access. An account with two-factor + login cannot log in from here (pass a session token instead).""" username: str password: str = field(repr=False) diff --git a/synpath/trading/init.py b/synpath/trading/init.py index c4da301..ad39b58 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -293,9 +293,9 @@ def ask_betfair(p: Prompter) -> dict[str, str]: def ask_smarkets(p: Prompter) -> dict[str, str]: - p.say("\nSmarkets: market data needs no login at all. A login (optional) sends a session token for") - p.say("undelayed quotes and your account's own rate limits. Betting needs Smarkets' approved API access") - p.say("(a one-off £150 fee), which this library does not support yet. The password is typed hidden.") + p.say("\nSmarkets: market data needs no login at all. A login sends a session token for undelayed") + p.say("quotes, your account's own rate limits and its orders, balance and positions. Placing orders") + p.say("needs Smarkets' approved API access (a one-off £150 fee). The password is typed hidden.") username = p.value("Smarkets username (your email)", check=_required("The username")) password = p.value("Smarkets password (typed hidden)", secret=True, check=_required("The password")) return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} @@ -390,7 +390,7 @@ def ask_gemini(p: Prompter) -> dict[str, str]: ("Limitless", ("LIMITLESS_PRIVATE_KEY",), ask_limitless), ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), - ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), + ("Smarkets (market data needs no login)", ("SMARKETS_USERNAME",), ask_smarkets), ("Novig", ("NOVIG_KEY_ID", "NOVIG_MANAGEMENT_KEY_ID"), ask_novig), ("Matchbook", ("MATCHBOOK_USERNAME",), ask_matchbook), ("ProphetX", ("PROPHETX_ACCESS_KEY", "PROPHETX_AFFILIATE_KEY"), ask_prophetx), diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 2170ae7..6c31051 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -60,11 +60,11 @@ # in the account's currency, to the penny, at least 1 (GBP) a bet. "betfair": {"default_tick": Decimal("0.00001"), "min_quantity": Decimal("0.01"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("1")}, - # No order entry yet; written down for when there is. Prices are basis - # points of 100% on an odds ladder (5000, 4762, 4545 are odds 2.0, 2.1, - # 2.2), so the tick here is the finest, one basis point. Quantities are - # pots in 1/10,000 GBP, so contracts step by 0.0001; the minimum stake is - # £0.05. + # Prices are basis points of 100% on an odds ladder (5000, 4762, 4545 are + # odds 2.0, 2.1, 2.2), so the tick here is the finest, one basis point, + # and the trading adapter snaps each order onto the ladder. Quantities + # are pots in 1/10,000 of the account's currency, so contracts step by + # 0.0001; the minimum back stake is £0.05. "smarkets": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.0001"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("0.05")}, # Prices on a grid of 0.001 near the ends (to 0.050, from 0.950) and diff --git a/synpath/trading/smarkets.py b/synpath/trading/smarkets.py new file mode 100644 index 0000000..5270eb4 --- /dev/null +++ b/synpath/trading/smarkets.py @@ -0,0 +1,921 @@ +"""Smarkets order entry: orders placed and cancelled, and the account's +orders, executions, positions and balance on the YES leg. + +Built to Smarkets' trading API reference (docs.smarkets.com, the OpenAPI +document at `api.smarkets.com/v0/control/openapi/`); its records follow the +reference's examples. Every call carries the session token of an account +login (`SMARKETS_USERNAME`, `SMARKETS_PASSWORD`); see +`synpath.smarkets.SmarketsSession`. **Placing orders needs Smarkets' approved +API access** (applied for, with a one-off £150 fee): an ordinary account +reads its balance, orders and statement here, and is refused a new order +with `PermissionDenied`. + +**An order buys or sells one contract's YES.** `buy` backs the contract +(takes its YES), `sell` lays it (takes its NO), and the price is the YES +price, as Smarkets' own `bids` (resting backs) are YES bids. Smarkets prices +in basis points of 100% (5000 = 50%), so there are no odds to invert. + +**Quantities are pots.** An order's `quantity` is what the pot (back stake +plus lay liability) would be if it matched, in 1/10,000 of the account's +currency; a pot of 1 pays 1 to the winning side, so it is one contract. An +order for `n` contracts sends `n * 10,000`, rounded down. The minimum is a +back stake of £0.05 (or the currency equivalent), which Smarkets checks. + +**Prices sit on an odds ladder.** Smarkets accepts only prices that are +decimal odds on the same bands as Betfair's (1.01 to 1000) turned into +basis points, plus the ends, 1 and 9999; every price in its public quotes +lies on it. A price within half a basis point of a tick is that tick; any +other is snapped onto the ladder on the cheaper side: a buy at the tick +below (it pays no more), a sell at the tick above (it receives no less). + +**Order types.** A limit is `good_til_halted` by default (Smarkets' own +default): it rests through the market going in play and is cancelled when +the market halts (a goal, a red card). `params={"order_type": +"keep_in_play"}` (or `{"keep_in_play": True}`) keeps it through halts too; +`"good_til_cancelled"` cancels it when the market goes in play. Every +resting order is cancelled when a reduction factor is applied (a +withdrawn runner). A market order or an IOC limit is `immediate_or_cancel` +at the price given as the worst accepted; a FOK is the same with +`minimum_accepted_quantity` set to the whole order, so nothing matches +unless all of it can. GTD, DAY and post-only orders are not offered. + +**No client order id; the label stands in.** Smarkets assigns the order id. +`client_order_id` (or `params["label"]`) is sent as the order's `label`, a +free string Smarkets keeps on the order and its statement rows, and is read +back as `client_order_id`. After a timeout, list the open orders and match +the label to find what was placed. + +**Matched bets can be pending.** In play, a matched bet waits eight seconds +(longer after a goal or a red card) before it is confirmed and can be voided +in that time; the pending part is counted as filled and rides in `info` +(`quantity_filled_pending`). + +**Positions** net per contract on the YES leg from the account's current +(unsettled) orders: backs matched are YES contracts, lays NO contracts. +Smarkets has no positions route. + +**Fees** are commission on the account's net winnings per Smarkets market at +settlement (2% on the standard tier; the account's rate is `commission_type` +on `/v3/accounts/`). + +**Rate limits.** 1200 requests per 60 s per account; placing and cancelling +one order 10 a second each; cancelling everything once a minute. A second +cancel-all inside the minute cancels the open orders one by one instead. + +There is no account stream: Smarkets' socket feed is for approved market +makers and its trading API is HTTP only, so the engine polls the orders. +""" +from __future__ import annotations + +import asyncio +import bisect +import time +from decimal import ROUND_DOWN, ROUND_HALF_EVEN, Decimal +from typing import Any +from urllib.parse import parse_qs + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..betfair import CLASSIC_LADDER +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NetworkError, RateLimitExceeded +from ..smarkets import ( + API_URL, DEFAULT_COMMISSION, PRICE_SCALE, QUANTITY_SCALE, SmarketsSession, contract_native_id, error_type, + fee_schedule_of, iso_ms, market_of, parse_contract_id, user_agent, +) +from ..types import Page +from .base import TradingExchange +from .credentials import SmarketsCredentials +from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, PermissionDenied +from .limiter import BudgetLimiter, Priority +from .types import ( + Account, Balance, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, + PositionSide, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "smarkets" +ONE = Decimal("1") +SCALE = Decimal(PRICE_SCALE) +UNITS = Decimal(QUANTITY_SCALE) +PRICE_PLACES = Decimal("0.0000000001") + +MAX_QUANTITY = 4_294_967_295 +"""The largest `quantity` an order takes, in 1/10,000 of the currency.""" + +PER_PAGE = 100 +"""Orders read per request when listing, Smarkets' maximum.""" + +ACTIVITY_PER_PAGE = 500 +"""Statement rows read per request, Smarkets' maximum.""" + +MAX_PAGES = 50 +"""Most pages one listing call reads.""" + +CANCEL_ALL_INTERVAL_S = 60.0 +"""Smarkets allows one cancel-all request per 60 s.""" + +RESTING_TYPES = ("good_til_halted", "keep_in_play", "good_til_cancelled") +"""Smarkets' resting order types: cancelled when the market halts, kept +through halts, cancelled when the market goes in play.""" + +OPEN_STATES = ("created", "partial") +CURRENT_STATES = ("created", "partial", "filled") +"""Smarkets order states: unmatched, partly matched, fully matched, then +`settled` once the contract has its outcome.""" + +OUTCOMES = {"winner": "yes", "loser": "no", "deadheat": "dead_heat", "reduce": "void", "void": "void"} +"""A settled order's contract outcome as the YES result, as the read adapter +names them.""" + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return Decimal("0") + return Decimal(str(value)) + + +def _ladder() -> tuple[int, ...]: + ticks = {int((SCALE / odds).quantize(ONE, rounding=ROUND_HALF_EVEN)) for odds in CLASSIC_LADDER} + return tuple(sorted(ticks | {1, PRICE_SCALE - 1})) + + +PRICE_LADDER: tuple[int, ...] = _ladder() +"""Every price Smarkets accepts, in basis points: the decimal odds 1.01 to +1000 in Betfair's bands, each as 10,000 / odds rounded to the nearest basis +point (half to even), with 1 and 9999 at the ends.""" +_TICKS = frozenset(PRICE_LADDER) + + +def price_bps(side: Side, price: Any) -> int: + """The ladder price in basis points for an order at this YES price: the + tick it names (within half a basis point), else the next tick on the + cheaper side, below for a buy and above for a sell.""" + p = D(price) + if not 0 < p < 1: + raise InvalidOrder(f"{VENUE}: a price is a YES probability strictly between 0 and 1, not {price}") + raw = p * SCALE + nearest = int(raw.quantize(ONE, rounding=ROUND_HALF_EVEN)) + if nearest in _TICKS: + return nearest + if side == Side.BUY: + index = bisect.bisect_right(PRICE_LADDER, raw) - 1 + if index < 0: + raise InvalidOrder(f"{VENUE}: {price} is below Smarkets' lowest price (0.0001)") + return PRICE_LADDER[index] + index = bisect.bisect_left(PRICE_LADDER, raw) + if index >= len(PRICE_LADDER): + raise InvalidOrder(f"{VENUE}: {price} is above Smarkets' highest price (0.9999)") + return PRICE_LADDER[index] + + +def quantity_units(quantity: Any) -> int: + """`quantity` contracts as Smarkets' pot, in 1/10,000 of the currency, + rounded down.""" + units = int((D(quantity) * UNITS).to_integral_value(rounding=ROUND_DOWN)) + if units <= 0: + raise InvalidOrder(f"{VENUE}: {quantity} contracts is less than Smarkets' smallest quantity (0.0001)") + if units > MAX_QUANTITY: + raise InvalidOrder(f"{VENUE}: {quantity} contracts is more than one order takes") + return units + + +def yes_price(bps: Any) -> Decimal: + """Basis points of 100% (possibly fractional) as the YES price.""" + return (D(bps) / SCALE).quantize(PRICE_PLACES).normalize() + + +def contracts(units: Any) -> Decimal: + """A pot in 1/10,000 of the currency as contracts paying 1 each.""" + return D(units) / UNITS + + +def _units(row: dict[str, Any], name: str) -> Any: + """A quantity in the account's currency where the row has one, else the + GBP figure (the same thing on a GBP account).""" + value = row.get(f"{name}_user_currency") + return value if value is not None else row.get(name) + + +def _market(row: dict[str, Any]) -> str: + return ids.qualify(VENUE, contract_native_id(row.get("market_id"), row.get("contract_id"))) + + +def side_of(native: Any) -> Side: + """`buy` backs the contract (YES); `sell` lays it (NO).""" + return Side.SELL if str(native or "").lower() == "sell" else Side.BUY + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def filled_units(row: dict[str, Any]) -> Decimal: + """What matched, in units: `quantity_filled`, plus the part still pending + confirmation where Smarkets counts it apart (filled, pending and + unfilled then add up to the order).""" + filled, pending = D(_units(row, "quantity_filled")), D(_units(row, "quantity_filled_pending")) + unfilled, total = D(_units(row, "quantity_unfilled")), D(_units(row, "quantity")) + if pending and filled + pending + unfilled == total: + filled += pending + return filled + + +def order_of_row(row: dict[str, Any], *, account: Account | None = None) -> Order: + """One order as `GET /v3/orders/` reports it. + + Smarkets has no cancelled state: an order in `created` or `partial` with + nothing left unmatched was cancelled (or was an immediate order's + unmatched rest), and a `settled` order that did not match in full had + its rest cancelled before it settled.""" + quantity = contracts(_units(row, "quantity")) + filled = contracts(filled_units(row)) + unfilled = contracts(_units(row, "quantity_unfilled")) + state = str(row.get("state") or "") + if state == "filled": + status = OrderStatus.CLOSED + elif state in OPEN_STATES or state == "settled": + if state != "settled" and unfilled > 0: + status = OrderStatus.OPEN + else: + status = OrderStatus.CLOSED if filled >= quantity else OrderStatus.CANCELED + else: + status = OrderStatus.PENDING + native_type = str(row.get("type") or "") + immediate = native_type in ("immediate_or_cancel", "over_the_counter", "over_the_counter_free") + average = row.get("average_price_matched_precise") or row.get("average_price_matched") + return Order( + id=str(row.get("id") or ""), + client_order_id=row.get("label") or None, + venue=VENUE, + account=account, + market_id=_market(row), + side=side_of(row.get("side")), + type=OrderType.LIMIT, + time_in_force=TimeInForce.IOC if immediate else TimeInForce.GTC, + status=status, + price=yes_price(row["price"]) if row.get("price") else None, + quantity=quantity, + filled=filled, + remaining=unfilled if status in (OrderStatus.OPEN, OrderStatus.PENDING) else Decimal("0"), + average_price=yes_price(average) if filled and average and D(average) > 0 else None, + created_at=iso_ms(row.get("created_datetime")), + updated_at=iso_ms(row.get("last_modified_datetime")), + info=row, + ) + + +def immediate(request: OrderRequest) -> bool: + """Whether the order takes what is there and leaves nothing resting.""" + return request.type == OrderType.MARKET or request.time_in_force in (TimeInForce.IOC, TimeInForce.FOK) + + +def order_after_placing(request: OrderRequest, row: dict[str, Any], *, account: Account) -> Order: + """The order from Smarkets' answer to `POST /v3/orders/`, with what the + request carried that the answer does not. An immediate order's answer + comes once it has matched what it could and been cancelled; any other + once the exchange accepted it.""" + quantity = contracts(row.get("quantity")) + executed = contracts(row.get("total_executed_quantity")) + if executed >= quantity > 0: + status, remaining = OrderStatus.CLOSED, Decimal("0") + elif immediate(request): + status, remaining = OrderStatus.CANCELED, Decimal("0") + else: + status = OrderStatus.OPEN + left = row.get("available_quantity") + remaining = contracts(left) if left is not None else quantity - executed + average = row.get("executed_avg_price") + now = int(time.time() * 1000) + return Order( + id=str(row.get("order_id") or ""), + client_order_id=request.client_order_id, + venue=VENUE, + account=request.account or account, + market_id=_market(row) if row.get("market_id") else ids.qualify(VENUE, ids.native(VENUE, request.market_id)), + side=side_of(row.get("side")) if row.get("side") else request.side, + type=request.type, + time_in_force=request.time_in_force, + status=status, + price=yes_price(row["price"]) if row.get("price") else None, + quantity=quantity, + filled=executed, + remaining=remaining, + average_price=yes_price(average) if executed and average else None, + created_at=now, + updated_at=now, + book=request.book, + trader=request.trader, + tags=request.tags, + info=row, + ) + + +def fill_of_activity(row: dict[str, Any], *, account: Account | None = None) -> Fill: + """One `order.execute` statement row as a fill: its price on the YES + leg, its pot in contracts.""" + units = _units(row, "quantity") + return Fill( + id=f"{row.get('seq')}-{row.get('subseq')}", + order_id=str(row.get("order_id") or ""), + client_order_id=row.get("label") or None, + venue=VENUE, + account=account, + market_id=_market(row), + side=side_of(row.get("side")), + price=yes_price(row.get("price")), + quantity=abs(contracts(units)), + fee=D(row["commission"]) if row.get("commission") is not None else None, + liquidity=Liquidity.UNKNOWN, + timestamp=iso_ms(row.get("timestamp")) or 0, + info=row, + ) + + +def _matched(row: dict[str, Any]) -> tuple[Decimal, Decimal]: + """(contracts matched, their average YES price) of one order.""" + filled = contracts(filled_units(row)) + average = row.get("average_price_matched_precise") or row.get("average_price_matched") or row.get("price") + return filled, (D(average) / SCALE if average else Decimal("0")) + + +def positions_of_orders(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: + """Unsettled orders netted per contract on the YES leg: matched backs are + YES contracts, matched lays NO contracts. `margin` is what the contract + loses at worst: what was paid (a back's stake, a lay's liability) less + what the losing side's bets return.""" + per: dict[str, dict[str, Any]] = {} + for row in rows: + if str(row.get("state") or "") == "settled": + continue + filled, price = _matched(row) + if filled <= 0: + continue + entry = per.setdefault(_market(row), {"yes": Decimal("0"), "no": Decimal("0"), "cost": Decimal("0"), + "traded": Decimal("0"), "orders": []}) + if side_of(row.get("side")) == Side.BUY: + entry["yes"] += filled + entry["cost"] += filled * price + else: + entry["no"] += filled + entry["cost"] += filled * (ONE - price) + entry["traded"] += filled * price + entry["orders"].append(row) + out = [] + for market_id, entry in per.items(): + yes, no, cost = entry["yes"], entry["no"], entry["cost"] + net = yes - no + worst = max(cost - yes, cost - no) + out.append(Position( + venue=VENUE, + account=account, + market_id=market_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), + inventory_yes=yes, + inventory_no=no, + # The average YES price of every matched bet, backs and lays. + entry_price=(entry["traded"] / (yes + no)).quantize(PRICE_PLACES), + margin=max(Decimal("0"), worst), + info={"orders": entry["orders"]}, + )) + return out + + +def settlements_of_orders(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Settlement]: + """Settled orders, one settlement per contract and side the account + matched. `payout` and `pnl` are before commission, which Smarkets takes + on the net winnings of the whole Smarkets market.""" + groups: dict[tuple[str, Side], list[dict[str, Any]]] = {} + for row in rows: + if str(row.get("state") or "") != "settled": + continue + if contracts(filled_units(row)) <= 0: + continue + groups.setdefault((_market(row), side_of(row.get("side"))), []).append(row) + out = [] + for (market_id, side), group in groups.items(): + quantity, cost = Decimal("0"), Decimal("0") + for row in group: + filled, price = _matched(row) + quantity += filled + cost += filled * (price if side == Side.BUY else ONE - price) + outcomes = {str(row.get("outcome") or "") for row in group} - {""} + result = OUTCOMES.get(outcomes.pop()) if len(outcomes) == 1 else None + won: bool | None = None + payout: Decimal | None = None + if result in ("yes", "no"): + won = (result == "yes") == (side == Side.BUY) + payout = quantity if won else Decimal("0") + elif result == "void": + payout = cost + stamps = [iso_ms(row.get("last_modified_datetime")) for row in group] + out.append(Settlement( + venue=VENUE, + account=account, + market_id=market_id, + held=PositionSide.LONG if side == Side.BUY else PositionSide.SHORT, + result=result, + won=won, + quantity=quantity, + cost=cost, + payout=payout, + pnl=payout - cost if payout is not None else None, + timestamp=max((s for s in stamps if s), default=None), + info={"orders": group, "pnl": "before commission"}, + )) + return out + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +def error_of(exc: ExchangeError) -> ExchangeError: + """Smarkets' refusal of an order as a typed error, by its `error_type`.""" + code = error_type(exc.body) or "" + text = f"{VENUE}: order refused: {code or exc}" + if "INSUFFICIENT_FUNDS" in code: + return InsufficientFunds(text, body=exc.body, status=exc.status) + if any(word in code for word in ("MARKET_HALTED", "TRADING_SUSPENDED", "MARKET_NOT_OPEN", "CONTRACT_SETTLED", + "MARKET_SETTLED")): + return MarketHalted(text, body=exc.body, status=exc.status) + if "INVALID_PRICE" in code or "INVALID_QUANTITY" in code or code in ( + "REQUEST_VALIDATION_ERROR", "ORDER_REJECTED_CONTRACT_NOT_FOUND", "ORDER_REJECTED_MARKET_NOT_FOUND"): + return InvalidOrder(text) + return OrderRejected(text, reason=code or None, info=exc.body if isinstance(exc.body, dict) else {}, + body=exc.body, status=exc.status) + + +def _next(next_page: Any, key: str) -> str | None: + """A paging value from Smarkets' `next_page` query string.""" + if not next_page: + return None + values = parse_qs(str(next_page).lstrip("?")).get(key) + return values[-1] if values else None + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.time_in_force in (TimeInForce.GTD, TimeInForce.DAY): + raise InvalidOrder(f"{VENUE}: Smarkets orders have no expiry; gtc, ioc or fok only") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") + if request.post_only: + raise InvalidOrder(f"{VENUE}: Smarkets' API has no post-only order") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: Smarkets nets every bet on a contract; there is no reduce-only order") + + +def venue_type(request: OrderRequest) -> str: + """Smarkets' order type for a request; see the module docstring.""" + if immediate(request): + return "immediate_or_cancel" + chosen = request.params.get("order_type") + if chosen is None: + chosen = "keep_in_play" if request.params.get("keep_in_play") else "good_til_halted" + if chosen not in RESTING_TYPES: + raise InvalidOrder(f"{VENUE}: order_type is one of {', '.join(RESTING_TYPES)}, not {chosen!r}") + return str(chosen) + + +def build_order(request: OrderRequest) -> dict[str, Any]: + """One request as Smarkets' `POST /v3/orders/` body, before anything is sent.""" + check_request(request) + market_id, contract_id = parse_contract_id(ids.native(VENUE, request.market_id)) + units = quantity_units(request.quantity) + body: dict[str, Any] = { + "market_id": market_id, + "contract_id": contract_id, + "side": "buy" if request.side == Side.BUY else "sell", + "price": price_bps(request.side, request.price), + "quantity": units, + "type": venue_type(request), + } + if request.time_in_force == TimeInForce.FOK: + body["minimum_accepted_quantity"] = units + elif immediate(request) and request.params.get("minimum_quantity") is not None: + body["minimum_accepted_quantity"] = min(units, quantity_units(request.params["minimum_quantity"])) + label = request.params.get("label") or request.client_order_id + if label: + body["label"] = str(label) + return body + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class SmarketsTrading(TradingExchange): + """Smarkets order entry. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.smarkets import SmarketsTrading + + async with SmarketsTrading(require("smarkets", load_credentials())) as smk: + print(await smk.fetch_balance()) + ``` + """ + + id = VENUE + name = "Smarkets" + has: dict[str, Capability] = { + "create_order": True, + # One request per order; not atomic. + "create_orders": True, + "cancel_order": True, + # One request per order. + "cancel_orders": True, + # Everywhere or per Smarkets market, once a minute; per contract by + # listing its open orders and cancelling each. + "cancel_all_orders": True, + # No amend route: cancel and replace. + "edit_order": False, + "fetch_order": True, + "fetch_open_orders": True, + # Smarkets keeps current and recently settled orders here. + "fetch_orders": "partial", + # From the account statement's executions. + "fetch_my_trades": True, + "fetch_positions": True, + "fetch_balance": True, + # Recently settled orders only. + "fetch_settlements": "partial", + "fetch_queue_position": False, + "fetch_fee_estimate": True, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: SmarketsCredentials | None = None, + *, + session: SmarketsSession | None = None, + session_token: str | None = None, + account_name: str = "default", + url: str = API_URL, + commission: float | None = None, + limiter: BudgetLimiter | None = None, + timeout: float = 30.0, + client: Any = None, + session_client: Any = None, + ): + """`credentials` log in on the first call; `session_token` reuses a + login made elsewhere (an account with two-factor login needs that). + `commission` overrides the rate fee estimates use, which is otherwise + the account's own (`commission_type`).""" + import httpx + + if session is None: + if credentials is None and not session_token: + from .errors import CredentialsMissing + + raise CredentialsMissing(f"{VENUE}: set SMARKETS_USERNAME and SMARKETS_PASSWORD, or pass session_token=") + session = SmarketsSession( + username=credentials.username if credentials else None, + password=credentials.password if credentials else None, + session_token=session_token, client=session_client, timeout=timeout, base_url=url, + ) + self.credentials = credentials + self.session = session + self.account = Account(venue=VENUE, name=account_name) + # 1200 requests a minute an account; placing and cancelling 10 a second each. + self.limiter = limiter or BudgetLimiter(read_per_second=8, write_per_second=10) + self.url = url + self.commission = commission + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + shared.headers["User-Agent"] = user_agent() + shared.headers["Accept"] = "application/json" + self.http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE) + # Cancel-all is not retried on a 429: the open orders are cancelled + # one by one instead. + self._cancel_all_http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE, attempts=1) + self._last_cancel_all: float | None = None + self._account_row: dict[str, Any] | None = None + + # -- plumbing ------------------------------------------------------------- + + async def _call( + self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, http: AsyncHttpClient | None = None, + ) -> Any: + """One call with the session token, paced; a 401 logs in again once.""" + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + query = {k: v for k, v in (params or {}).items() if v is not None and v != []} + for attempt in range(2): + token = await asyncio.to_thread(self.session.token) + try: + return await (http or self.http).request( + method, path, params=query or None, json=body, headers={"Authorization": f"Session-Token {token}"}, + ) + except AuthenticationError as exc: + if exc.status == 401 and attempt == 0: + self.session.invalidate() + continue + if exc.status == 403: + code = error_type(exc.body) + if code is None: + raise PermissionDenied(f"{VENUE}: refused by Smarkets' Cloudflare front (403)", + body=exc.body, status=403) from None + hint = ("; placing orders needs Smarkets' approved API access" if code == "FORBIDDEN" + and path.startswith("/v3/orders/") else "") + raise PermissionDenied(f"{VENUE}: refused: {code}{hint}", body=exc.body, status=403) from None + raise + raise AssertionError("unreachable") # pragma: no cover + + async def account_info(self) -> dict[str, Any]: + """The account (`GET /v3/accounts/`): its currency, balance, exposure + and commission rate. The latest read is kept for the currency and the + rate.""" + raw = await self._call("GET", "/v3/accounts/") or {} + self._account_row = dict(raw.get("account") or {}) + return self._account_row + + async def currency(self) -> str: + """The account's currency, read once: every quantity and amount is in it.""" + if self._account_row is None: + await self.account_info() + return str((self._account_row or {}).get("currency") or "GBP") + + async def commission_rate(self) -> float: + """The commission rate fee estimates use: `commission` if given, else + the account's `commission_type`, else the standard 2%.""" + if self.commission is not None: + return float(self.commission) + if self._account_row is None: + await self.account_info() + try: + return float((self._account_row or {}).get("commission_type") or DEFAULT_COMMISSION) + except ValueError: + return DEFAULT_COMMISSION + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest) -> Order: + """Place one order. An immediate one answers once it has matched what + it could; a resting one once the exchange accepted it, so a fill + straight after shows on `fetch_order`.""" + body = build_order(request) + try: + row = await self._call("POST", "/v3/orders/", body=body, kind="write") + except BadRequest as exc: + raise error_of(exc) from None + if not isinstance(row, dict) or not row.get("order_id"): + raise OrderRejected(f"{VENUE}: the order was answered without an id", info=row if isinstance(row, dict) else {}) + return order_after_placing(request, row, account=self.account) + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many orders, one request each (Smarkets has no batch route), one + result each in the order asked for. A refused order comes back as its + exception.""" + out: list[Order | Exception] = [] + for request in requests: + try: + out.append(await self.create_order(request)) + except (ExchangeError, NetworkError) as exc: + out.append(exc) + return out + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one order and read it back. One Smarkets refuses to cancel + (already matched, too late) is read back as it stands; a cancel the + exchange has not applied yet reads `pending_cancel`.""" + try: + await self._call("DELETE", f"/v3/orders/{order_id}/", kind="write", priority=Priority.HIGH) + except MarketNotFound as exc: + raise OrderNotFound(f"{VENUE}: no order {order_id}", body=exc.body, status=exc.status) from None + except BadRequest as exc: + order = await self.fetch_order(order_id) + if order.is_terminal: + return order + raise OrderRejected(f"{VENUE}: cancel of {order_id} refused: {error_type(exc.body) or exc}", + reason=error_type(exc.body) or "cancel_refused", body=exc.body) from None + order = await self.fetch_order(order_id) + if order.status == OrderStatus.OPEN: + return order.model_copy(update={"status": OrderStatus.PENDING_CANCEL}) + return order + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel each order, one request each, one result each.""" + out: list[Order | Exception] = [] + for order_id in order_ids: + try: + out.append(await self.cancel_order(order_id)) + except (ExchangeError, NetworkError) as exc: + out.append(exc) + return out + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int | None: + """Every open order, on a Smarkets market (`smarkets:`), on + one contract, or on the account. + + The account or a Smarkets market is one request, which Smarkets + acknowledges without a count (`None`). It allows one a minute: a + second inside the minute, a refused one (429), and a contract (which + cancel-all cannot name) cancel the open orders one by one, returning + how many were cancelled.""" + native = ids.native(VENUE, market_id) if market_id else None + whole_market = native is None or "-" not in native + recent = self._last_cancel_all is not None and time.monotonic() - self._last_cancel_all < CANCEL_ALL_INTERVAL_S + if whole_market and not recent: + self._last_cancel_all = time.monotonic() + try: + await self._call("DELETE", "/v3/orders/", params={"market_id": market_of(native) if native else None}, + kind="write", priority=Priority.HIGH, http=self._cancel_all_http) + return None + except RateLimitExceeded: + pass + orders = await self.fetch_open_orders(market_id=market_id) + results = await self.cancel_orders([o.id for o in orders]) + return sum(1 for r in results if isinstance(r, Order)) + + async def fetch_order(self, order_id: str) -> Order: + """One order by id, in any state Smarkets still lists.""" + raw = await self._call("GET", "/v3/orders/", params={"id": [str(order_id)]}) or {} + rows = [r for r in raw.get("orders") or [] if isinstance(r, dict) and str(r.get("id")) == str(order_id)] + if rows: + return order_of_row(rows[0], account=self.account) + try: + row = await self._call("GET", f"/v3/orders/{order_id}/") + except MarketNotFound as exc: + raise OrderNotFound(f"{VENUE}: no order {order_id}", body=exc.body, status=exc.status) from None + if not isinstance(row, dict) or not row.get("id"): + raise OrderNotFound(f"{VENUE}: no order {order_id}") + return order_of_row(row, account=self.account) + + def _scope(self, market_id: str | None) -> dict[str, Any]: + """Smarkets' filter for a contract (`-`) or a + Smarkets market (a bare market id, the Synpath event).""" + if not market_id: + return {} + native = ids.native(VENUE, market_id) + if "-" in native: + market, contract = parse_contract_id(native) + return {"market_id": [market], "contract_id": contract} + return {"market_id": [market_of(native)]} + + async def _orders(self, params: dict[str, Any], *, after: str | None = None, + pages: int = MAX_PAGES, limit: int = PER_PAGE) -> tuple[list[dict[str, Any]], str | None]: + """Order rows by id from `after`, page by page; and the id to resume + after, when more are left.""" + rows: list[dict[str, Any]] = [] + for _ in range(pages): + raw = await self._call("GET", "/v3/orders/", params={ + **params, "limit": limit, "sort": "id", "pagination_last_id": after, + "include_promotion_data": "false", + }) or {} + page = [r for r in raw.get("orders") or [] if isinstance(r, dict)] + rows += page + after = _next((raw.get("pagination") or {}).get("next_page"), "pagination_last_id") + if not page or not after: + return rows, None + return rows, after + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Every resting order, or every one on a contract or Smarkets market.""" + rows, _ = await self._orders({"state": list(OPEN_STATES), **self._scope(market_id)}) + orders = [order_of_row(row, account=self.account) for row in rows] + return [o for o in orders if o.status == OrderStatus.OPEN] + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of the orders Smarkets lists (current and recently + settled), oldest first: `open`, `closed` (fully matched) or + `canceled` (ended with something unmatched). `since` keeps orders + created since then.""" + native = {"open": list(OPEN_STATES), "closed": ["filled", "settled"], "canceled": list(OPEN_STATES) + ["settled"]} + if status is not None and status not in native: + raise BadRequest(f"{VENUE}: status is open, closed or canceled") + if cursor is not None and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params: dict[str, Any] = {"state": native.get(status or ""), **self._scope(market_id)} + if since is not None: + from ..types import iso + + params["created_datetime_min"] = iso(since) + rows, following = await self._orders(params, after=cursor, pages=1, limit=min(limit or PER_PAGE, PER_PAGE)) + orders = [order_of_row(row, account=self.account) for row in rows] + wanted = {"open": OrderStatus.OPEN, "closed": OrderStatus.CLOSED, "canceled": OrderStatus.CANCELED}.get(status or "") + if wanted is not None: + orders = [o for o in orders if o.status == wanted] + return Page(orders, next_cursor=following) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """The account's executions from its statement (`order.execute` + rows), newest first.""" + params: dict[str, Any] = {"source": ["order.execute"], "sort": "-seq,-subseq", + "limit": min(limit or ACTIVITY_PER_PAGE, ACTIVITY_PER_PAGE), + "include_promotion_data": "false"} + wanted_contract = None + if market_id: + native = ids.native(VENUE, market_id) + params["market_id"] = [market_of(native)] + if "-" in native: + wanted_contract = parse_contract_id(native)[1] + if order_id: + params["order_id"] = [str(order_id)] + if since is not None: + from ..types import iso + + params["timestamp_min"] = iso(since) + if cursor: + seq, sep, subseq = cursor.partition(":") + if not sep or not seq.isdigit() or not subseq.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params["pagination_last_seq"], params["pagination_last_subseq"] = seq, subseq + raw = await self._call("GET", "/v3/accounts/activity/", params=params) or {} + rows = [r for r in raw.get("account_activity") or [] if isinstance(r, dict) + and r.get("source") == "order.execute" and r.get("contract_id")] + if wanted_contract: + rows = [r for r in rows if str(r.get("contract_id")) == wanted_contract] + fills = [fill_of_activity(row, account=self.account) for row in rows] + currency = await self.currency() + fills = [f.model_copy(update={"fee_currency": currency}) if f.fee is not None else f for f in fills] + next_page = (raw.get("pagination") or {}).get("next_page") + seq = _next(next_page, "pagination_last_seq") + subseq = _next(next_page, "pagination_last_subseq") + last = raw.get("account_activity") or [] + if next_page and (seq is None or subseq is None) and last: + seq, subseq = str(last[-1].get("seq")), str(last[-1].get("subseq")) + return Page(fills, next_cursor=f"{seq}:{subseq}" if next_page and seq and subseq else None) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """Cash (`balance`), what bets hold (`exposure`, `locked`), and what + is free to bet (`available_balance`).""" + row = await self.account_info() + available = D(row.get("available_balance")) + return Balance( + venue=VENUE, account=account or self.account, currency=str(row.get("currency") or "GBP"), + total=D(row.get("balance")), available=available, locked=abs(D(row.get("exposure"))), + buying_power=available, timestamp=int(time.time() * 1000), info=row, + ) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """The account's matched, unsettled bets netted per contract on the + YES leg. `market_id` is a contract, `event_id` a Smarkets market.""" + rows, _ = await self._orders({"state": list(CURRENT_STATES), **self._scope(market_id or event_id)}) + positions = positions_of_orders(rows, account=self.account) + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + positions = [p for p in positions if p.market_id == wanted] + return positions + + async def fetch_settlements( + self, *, market_id: str | None = None, since: int | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Settlement]: + """One page of recently settled orders, one settlement per contract + and side. `since` keeps orders created since then.""" + if cursor is not None and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params: dict[str, Any] = {"state": ["settled"], **self._scope(market_id)} + if since is not None: + from ..types import iso + + params["created_datetime_min"] = iso(since) + rows, following = await self._orders(params, after=cursor, pages=1) + settlements = settlements_of_orders(rows, account=self.account) + return Page(settlements[:limit] if limit else settlements, next_cursor=following) + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The commission on `quantity` contracts at the YES `price` if the bet + wins: a share of its profit. A `sell` lays the contract, which wins + `price` a contract when it loses. Makers pay the same.""" + native = ids.native(VENUE, market_id) + schedule = fee_schedule_of(market_of(native), await self.commission_rate()) + paid = D(price) if side == Side.BUY else ONE - D(price) + fee = schedule.estimate(float(paid), float(quantity), taker=True) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, native), side=side, price=D(price), quantity=D(quantity), + taker_fee=D(fee) if fee is not None else None, maker_fee=D(fee) if fee is not None else None, + currency=await self.currency(), + info={"schedule": schedule.model_dump(), "charged": "at settlement, on net winnings in the Smarkets market"}, + ) + + async def close(self) -> None: + await self.http.close() + self.session.close() + + +__all__ = [ + "SmarketsTrading", "PRICE_LADDER", "build_order", "price_bps", "quantity_units", "order_of_row", + "order_after_placing", "fill_of_activity", "positions_of_orders", "settlements_of_orders", +] diff --git a/tests/samples/smarkets_trading.json b/tests/samples/smarkets_trading.json new file mode 100644 index 0000000..be4db3a --- /dev/null +++ b/tests/samples/smarkets_trading.json @@ -0,0 +1,235 @@ +{ + "login": { + "created_social_member": true, + "factor": "complete", + "stop": "2018-01-24T19:49:43.964757", + "token": "WzM2NjI1MDIsMjIyMDIxMzJd.jKWaw5j3XtYX6huY7HZVEvISu6k", + "verify": false + }, + "create": { + "available_balance": "100.00", + "available_quantity": 15000, + "contract_id": "123456", + "executed_avg_price": 5000, + "exposure": "-50.00", + "market_id": "128939", + "order_id": "1518455672861", + "orig_price": 5000, + "price": 5000, + "quantity": 10000, + "side": "buy", + "total_executed_quantity": 15000 + }, + "order": { + "average_price_matched": 5000, + "average_price_matched_precise": "5000", + "best_price": { + "price": 5000, + "source": "bet365" + }, + "bonus_bet": false, + "contract_id": "123456", + "created_datetime": "2012-08-14T00:56:45.291688Z", + "id": "1518455672861", + "label": "strategy_1", + "last_modified_datetime": "2012-08-14T00:56:45.291688Z", + "market_id": "128939", + "original_order_id": "1518455672861", + "otc_available": false, + "outcome": "winner", + "price": 5000, + "quantity": 10000, + "quantity_filled": 10000, + "quantity_filled_pending": 2500, + "quantity_filled_pending_user_currency": 2500, + "quantity_filled_user_currency": 10000, + "quantity_traded_out_user_currency": 10000, + "quantity_unfilled": 2500, + "quantity_unfilled_user_currency": 2500, + "quantity_user_currency": 10000, + "sequence_number": 94115, + "side": "buy", + "stake_traded_out_user_currency": "24.08", + "state": "created", + "token_used": { + "bet_token_type": "free_bet", + "boost_percent": 1.0, + "original_price": 5000 + }, + "type": "good_til_halted" + }, + "orders": { + "orders": [ + { + "average_price_matched": 5000, + "average_price_matched_precise": "5000", + "best_price": { + "price": 5000, + "source": "bet365" + }, + "bonus_bet": false, + "contract_id": "123456", + "created_datetime": "2012-08-14T00:56:45.291688Z", + "id": "1518455672861", + "label": "strategy_1", + "last_modified_datetime": "2012-08-14T00:56:45.291688Z", + "market_id": "128939", + "original_order_id": "1518455672861", + "otc_available": false, + "outcome": "winner", + "price": 5000, + "quantity": 10000, + "quantity_filled": 10000, + "quantity_filled_pending": 2500, + "quantity_filled_pending_user_currency": 2500, + "quantity_filled_user_currency": 10000, + "quantity_traded_out_user_currency": 10000, + "quantity_unfilled": 2500, + "quantity_unfilled_user_currency": 2500, + "quantity_user_currency": 10000, + "sequence_number": 94115, + "side": "buy", + "stake_traded_out_user_currency": "24.08", + "state": "created", + "token_used": { + "bet_token_type": "free_bet", + "boost_percent": 1.0, + "original_price": 5000 + }, + "type": "good_til_halted" + } + ], + "otc_available": false, + "pagination": { + "next_page": "?limit=20&pagination_last_id=810214202189041" + } + }, + "account": { + "account": { + "account_id": "123556546", + "available_balance": "100.00", + "balance": "150.00", + "bonus_balance": "10.00", + "commission_type": "0.02", + "currency": "GBP", + "exposure": "-50.00", + "is_first_time_deposit": true, + "signup_date": "2021-01-01T00:00:00.000000" + } + }, + "activity": { + "account_activity": [ + { + "amount": "-19.00", + "bet_token_type": "free_bet", + "bonus_bet": false, + "bonus_change": "10.00", + "commission": "2.20", + "contract_id": "123456", + "event_id": "565413", + "exposure": "-50.00", + "extra": "market_halted", + "label": "strategy_1", + "market_id": "128939", + "money": "483.56", + "money_change": "2.20", + "order_id": "1518455672861", + "price": 5000, + "quantity": 10000, + "quantity_change": -290023, + "quantity_user_currency": 10000, + "quantity_user_currency_change": -2819, + "seq": 11423, + "side": "buy", + "source": "admin.credit", + "subseq": 123242, + "timestamp": "2017-02-01T10:23:54.361612" + } + ], + "contracts": [ + { + "name": "Arsenal", + "state_or_outcome": "live" + } + ], + "events": [ + { + "full_slug": "/sport/horse-racing/ascot/2017/12/22/15:40", + "name": "15:40", + "parent_id": "565413", + "slug": "ascot-2017-12-22T00:00:00-15-40", + "state": "new", + "type": "horse_racing_race" + } + ], + "markets": [ + { + "contract_selections": [ + 254245, + 4565452, + 7894545 + ], + "market_type": { + "name": "OVER_UNDER", + "param": "2.5", + "params": { + "HANDICAP": "1.5", + "TOTAL": "151.5" + } + }, + "name": "To win" + } + ], + "pagination": { + "next_page": "?limit=20&sort=seq,subseq&pagination_last_seq=20&pagination_last_seq=3" + } + }, + "cancel": {}, + "cancel_all": {}, + "errors": { + "ORDER_REJECTED_INSUFFICIENT_FUNDS": { + "data": null, + "error_type": "ORDER_REJECTED_INSUFFICIENT_FUNDS" + }, + "ORDER_REJECTED_MARKET_HALTED": { + "data": null, + "error_type": "ORDER_REJECTED_MARKET_HALTED" + }, + "ORDER_INVALID_INVALID_PRICE": { + "data": null, + "error_type": "ORDER_INVALID_INVALID_PRICE" + }, + "ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY": { + "data": null, + "error_type": "ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY" + }, + "ORDER_REJECTED_CROSSED_SELF": { + "data": null, + "error_type": "ORDER_REJECTED_CROSSED_SELF" + }, + "FORBIDDEN": { + "data": null, + "error_type": "FORBIDDEN" + }, + "AUTH_REQUIRED": { + "data": null, + "error_type": "AUTH_REQUIRED" + }, + "ORDER_NOT_FOUND": { + "data": null, + "error_type": "ORDER_NOT_FOUND" + }, + "ORDER_CANCEL_REJECTED_NOT_FOUND": { + "data": null, + "error_type": "ORDER_CANCEL_REJECTED_NOT_FOUND" + }, + "ORDER_CANCEL_REJECTED_TOO_LATE": { + "data": null, + "error_type": "ORDER_CANCEL_REJECTED_TOO_LATE" + }, + "RATE_LIMIT_EXCEEDED": { + "data": null, + "error_type": "RATE_LIMIT_EXCEEDED" + } + } +} diff --git a/tests/test_smarkets.py b/tests/test_smarkets.py index 9ad683e..6d6dce9 100644 --- a/tests/test_smarkets.py +++ b/tests/test_smarkets.py @@ -507,9 +507,9 @@ def test_declared_honestly(self): assert has["fetch_markets"] is True and has["fetch_order_books"] is True and has["search"] is True assert has["fetch_trades"] == "partial" and has["fetch_ohlcv"] is False and has["sort"] is False - def test_order_entry_is_not_offered(self): - with pytest.raises(NotSupported, match="market data only"): - synpath.Client().trading("smarkets") + def test_order_entry_needs_the_login(self): + with pytest.raises(CredentialsMissing, match="SMARKETS_USERNAME"): + synpath.Client({"smarkets": None}).trading("smarkets") def test_venue_rules_note_the_minimum_stake(self): from decimal import Decimal diff --git a/tests/test_smarkets_trading.py b/tests/test_smarkets_trading.py new file mode 100644 index 0000000..0f25240 --- /dev/null +++ b/tests/test_smarkets_trading.py @@ -0,0 +1,528 @@ +"""Smarkets order entry: orders built from requests, Smarkets' records read on +the YES leg, and the adapter against a scripted venue. + +Records in `tests/samples/smarkets_trading.json` are assembled from the +field examples of Smarkets' trading API reference (the OpenAPI document +behind docs.smarkets.com), since betting needs an approved API account: each +response is its schema with every field at its documented example. The login +example lost its NemID fields (`nemid_session_id`, `redirect_url`) and has +`verify` false; the error bodies are the documented `{"data", "error_type"}` +shape with documented codes. Tests change single fields where a scenario +needs consistent numbers. Every request goes to an `httpx.MockTransport`.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +import httpx +import pytest + +from synpath.trading.credentials import SmarketsCredentials +from synpath.trading.errors import ( + CredentialsMissing, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, PermissionDenied, +) +from synpath.trading.smarkets import ( + PRICE_LADDER, SmarketsTrading, build_order, fill_of_activity, order_after_placing, order_of_row, + positions_of_orders, price_bps, quantity_units, settlements_of_orders, +) +from synpath.trading.types import OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce + +from conftest import load + +S = load("smarkets_trading.json") +CONTRACT = "smarkets:128939-123456" +ORDER_ID = "1518455672861" + + +def request(**changes) -> OrderRequest: + base = {"market_id": CONTRACT, "side": Side.BUY, "quantity": Decimal("10"), "price": Decimal("0.5")} + return OrderRequest(**{**base, **changes}) + + +def row(base: dict, **changes) -> dict: + return {**base, **changes} + + +def resting(**changes) -> dict: + """The documented order as a consistent unmatched one: 10 contracts, none matched.""" + return row(S["order"], **{ + "state": "created", "quantity": 100000, "quantity_user_currency": 100000, "quantity_filled": 0, + "quantity_filled_user_currency": 0, "quantity_filled_pending": 0, "quantity_filled_pending_user_currency": 0, + "quantity_unfilled": 100000, "quantity_unfilled_user_currency": 100000, "outcome": None, **changes, + }) + + +class Venue: + """Answers requests by `METHOD /path`, and records them.""" + + def __init__(self, **answers): + self.answers = {"POST /v3/sessions/": httpx.Response(201, json=S["login"]), **answers} + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + answer = self.answers.get(f"{req.method} {req.url.path}") + if answer is None: + return httpx.Response(404, json={"data": None, "error_type": "NOT_FOUND"}) + if callable(answer): + answer = answer(req) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + def sent(self, method: str, path: str) -> list[httpx.Request]: + return [r for r in self.requests if r.method == method and r.url.path == path] + + +def adapter(venue: Venue, **kwargs) -> SmarketsTrading: + creds = SmarketsCredentials(username="john.doe@company.com", password="verysecurepassword") + return SmarketsTrading( + creds, client=httpx.AsyncClient(transport=httpx.MockTransport(venue)), + session_client=httpx.Client(transport=httpx.MockTransport(venue)), **kwargs, + ) + + +def run(coro): + return asyncio.run(coro) + + +def error(code: str, status: int = 400) -> httpx.Response: + return httpx.Response(status, json=S["errors"][code]) + + +class TestOrders: + def test_a_buy_backs_the_contract_at_its_price_in_basis_points(self): + assert build_order(request(client_order_id="c-1")) == { + "market_id": "128939", "contract_id": "123456", "side": "buy", "price": 5000, "quantity": 100000, + "type": "good_til_halted", "label": "c-1", + } + + def test_a_sell_lays_it(self): + body = build_order(request(side=Side.SELL, price=Decimal("0.4"))) + assert body["side"] == "sell" and body["price"] == 4000 + + def test_the_ladder_is_betfairs_odds_in_basis_points_with_the_ends(self): + assert PRICE_LADDER[0] == 1 and PRICE_LADDER[-1] == 9999 + # Odds 2.0, 2.1, 2.2; and 32 and 6.4, whose half basis points round to even. + assert {5000, 4762, 4545, 312, 1562}.issubset(PRICE_LADDER) + # Every price in the recorded public quotes is on it. + quotes = load("smarkets_quotes.json") + seen = {level["price"] for contract in quotes.values() for side in ("bids", "offers") + for level in contract.get(side) or []} + assert seen and seen <= set(PRICE_LADDER) + + def test_prices_snap_to_the_ladder_on_the_cheaper_side(self): + # 1/2.1 is the tick 4762 for either side, within half a basis point. + assert price_bps(Side.BUY, Decimal("0.476190")) == price_bps(Side.SELL, Decimal("0.476190")) == 4762 + # 0.475 lies between 4717 (odds 2.12) and 4762 (odds 2.1): a buy pays no more, a sell receives no less. + assert price_bps(Side.BUY, Decimal("0.475")) == 4717 + assert price_bps(Side.SELL, Decimal("0.475")) == 4762 + assert price_bps(Side.BUY, Decimal("0.0001")) == 1 and price_bps(Side.SELL, Decimal("0.9999")) == 9999 + + def test_prices_outside_the_book_are_refused(self): + for bad in (Decimal("0"), Decimal("1"), Decimal("1.2")): + with pytest.raises(InvalidOrder): + price_bps(Side.BUY, bad) + with pytest.raises(InvalidOrder): + price_bps(Side.BUY, Decimal("0.00004")) + with pytest.raises(InvalidOrder): + price_bps(Side.SELL, Decimal("0.99996")) + + def test_quantities_are_pots_in_ten_thousandths_rounded_down(self): + assert quantity_units(Decimal("12.34567")) == 123456 + with pytest.raises(InvalidOrder): + quantity_units(Decimal("0.00001")) + with pytest.raises(InvalidOrder): + quantity_units(Decimal("500000")) + + def test_order_types(self): + assert build_order(request(params={"keep_in_play": True}))["type"] == "keep_in_play" + assert build_order(request(params={"order_type": "good_til_cancelled"}))["type"] == "good_til_cancelled" + ioc = build_order(request(time_in_force=TimeInForce.IOC, params={"minimum_quantity": "4"})) + assert ioc["type"] == "immediate_or_cancel" and ioc["minimum_accepted_quantity"] == 40000 + market = build_order(request(type=OrderType.MARKET)) + assert market["type"] == "immediate_or_cancel" and "minimum_accepted_quantity" not in market + fok = build_order(request(time_in_force=TimeInForce.FOK)) + assert fok["type"] == "immediate_or_cancel" and fok["minimum_accepted_quantity"] == 100000 + + def test_a_label_in_params_wins(self): + assert build_order(request(client_order_id="c-1", params={"label": "strategy_1"}))["label"] == "strategy_1" + assert "label" not in build_order(request()) + + @pytest.mark.parametrize("changes", [ + {"time_in_force": TimeInForce.GTD, "expires_at": 1}, {"time_in_force": TimeInForce.DAY}, + {"post_only": True}, {"reduce_only": True}, {"type": OrderType.ICEBERG}, + {"type": OrderType.MARKET, "price": None}, {"market_id": "smarkets:128939"}, + {"params": {"order_type": "immediate_or_cancel"}}, + ]) + def test_what_smarkets_does_not_hold_is_refused(self, changes): + from synpath.errors import BadRequest + + with pytest.raises(BadRequest): # InvalidOrder is a BadRequest + build_order(request(**changes)) + + +class TestRecords: + def test_the_documented_order(self): + order = order_of_row(S["order"]) + assert order.id == ORDER_ID and order.market_id == CONTRACT and order.side == Side.BUY + assert order.client_order_id == "strategy_1" and order.price == Decimal("0.5") + assert order.quantity == Decimal("1") and order.created_at == 1344905805291 + # Filled 10000 and unfilled 2500 do not add up with the pending 2500, so + # the pending part is taken as already inside `quantity_filled`. + assert order.filled == Decimal("1") and order.average_price == Decimal("0.5") + + def test_an_unmatched_order_rests(self): + order = order_of_row(resting()) + assert order.status == OrderStatus.OPEN and order.filled == 0 and order.remaining == Decimal("10") + assert order.time_in_force == TimeInForce.GTC and order.average_price is None + + def test_pending_matches_count_as_filled_when_smarkets_counts_them_apart(self): + order = order_of_row(resting(state="partial", quantity_filled_user_currency=20000, + quantity_filled_pending_user_currency=30000, + quantity_unfilled_user_currency=50000)) + assert order.status == OrderStatus.OPEN and order.filled == Decimal("5") and order.remaining == Decimal("5") + + def test_nothing_left_unmatched_is_cancelled_or_closed(self): + cancelled = order_of_row(resting(quantity_unfilled_user_currency=0)) + assert cancelled.status == OrderStatus.CANCELED and cancelled.remaining == 0 + partly = order_of_row(resting(state="partial", quantity_filled_user_currency=40000, + quantity_unfilled_user_currency=0)) + assert partly.status == OrderStatus.CANCELED and partly.filled == Decimal("4") + filled = order_of_row(resting(state="filled", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0)) + assert filled.status == OrderStatus.CLOSED and filled.filled == filled.quantity + + def test_an_immediate_order_is_ioc(self): + assert order_of_row(resting(type="immediate_or_cancel")).time_in_force == TimeInForce.IOC + + def test_a_lay_is_a_sell(self): + assert order_of_row(resting(side="sell")).side == Side.SELL + + def test_a_placement(self): + placed = order_after_placing(request(client_order_id="c-1"), row(S["create"], quantity=100000, + total_executed_quantity=0, available_quantity=100000, executed_avg_price=None), + account=None) # type: ignore[arg-type] + assert placed.id == ORDER_ID and placed.status == OrderStatus.OPEN and placed.remaining == Decimal("10") + assert placed.client_order_id == "c-1" and placed.market_id == CONTRACT + + def test_a_placement_that_matched_in_full_is_closed(self): + placed = order_after_placing(request(), S["create"], account=None) # type: ignore[arg-type] + assert placed.status == OrderStatus.CLOSED and placed.remaining == 0 and placed.average_price == Decimal("0.5") + + def test_an_ioc_placement_is_cancelled_after_what_matched(self): + placed = order_after_placing(request(time_in_force=TimeInForce.IOC), + row(S["create"], quantity=100000, total_executed_quantity=30000, + available_quantity=70000), account=None) # type: ignore[arg-type] + assert placed.status == OrderStatus.CANCELED and placed.filled == Decimal("3") and placed.remaining == 0 + + def test_an_execution_is_a_fill(self): + fill = fill_of_activity(row(S["activity"]["account_activity"][0], source="order.execute")) + assert fill.id == "11423-123242" and fill.order_id == ORDER_ID and fill.market_id == CONTRACT + assert fill.price == Decimal("0.5") and fill.quantity == Decimal("1") and fill.side == Side.BUY + assert fill.fee == Decimal("2.20") and fill.timestamp == 1485944634361 + + def test_positions_net_backs_and_lays_per_contract(self): + back = resting(state="filled", quantity_filled_user_currency=100000, quantity_unfilled_user_currency=0, + average_price_matched_precise="4000") + lay = resting(id="2", side="sell", state="partial", quantity_filled_user_currency=40000, + quantity_unfilled_user_currency=60000, average_price_matched_precise="5000") + settled = resting(id="3", state="settled", quantity_filled_user_currency=100000) + [position] = positions_of_orders([back, lay, settled]) + assert position.market_id == CONTRACT and position.side == PositionSide.LONG + assert position.contracts == Decimal("6") and position.inventory_yes == 10 and position.inventory_no == 4 + # 10 at 0.40 and 4 at 0.50 on the YES leg. + assert position.entry_price == (Decimal("6") / Decimal("14")).quantize(Decimal("1e-10")) + # Paid 4.00 backing and 2.00 laying; if the contract loses, the lays return 4. + assert position.margin == Decimal("2") + + def test_settlements_read_the_outcome_on_the_yes_leg(self): + won = resting(state="settled", outcome="winner", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0, average_price_matched_precise="4000") + lay = resting(id="2", side="sell", state="settled", outcome="winner", quantity_filled_user_currency=50000, + quantity_unfilled_user_currency=0, average_price_matched_precise="4000") + back, laid = settlements_of_orders([won, lay, resting()]) + assert back.held == PositionSide.LONG and back.result == "yes" and back.won is True + assert back.quantity == 10 and back.cost == Decimal("4") and back.payout == 10 and back.pnl == Decimal("6") + assert laid.held == PositionSide.SHORT and laid.won is False and laid.cost == Decimal("3") and laid.pnl == -3 + void = settlements_of_orders([row(won, outcome="void")])[0] + assert void.result == "void" and void.pnl == 0 + + +class TestAdapter: + def test_login_then_the_session_token_on_every_call(self): + venue = Venue(**{"POST /v3/orders/": row(S["create"], quantity=100000, total_executed_quantity=0, + available_quantity=100000)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_order(request(client_order_id="c-1")) + + order = run(go()) + login, submit = venue.requests + assert json.loads(login.content) == {"username": "john.doe@company.com", "password": "verysecurepassword"} + assert submit.headers["Authorization"] == f"Session-Token {S['login']['token']}" + assert json.loads(submit.content)["label"] == "c-1" + assert order.status == OrderStatus.OPEN and order.client_order_id == "c-1" + + def test_an_expired_session_logs_in_again_once(self): + answers = iter([error("AUTH_REQUIRED", 401), httpx.Response(200, json=S["account"])]) + venue = Venue(**{"GET /v3/accounts/": lambda req: next(answers)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_balance() + + balance = run(go()) + assert len(venue.sent("POST", "/v3/sessions/")) == 2 + assert (balance.total, balance.available, balance.locked) == (Decimal("150.00"), Decimal("100.00"), Decimal("50.00")) + assert balance.currency == "GBP" + + def test_an_account_without_api_access_is_refused_with_a_reason(self): + venue = Venue(**{"POST /v3/orders/": error("FORBIDDEN", 403)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_order(request()) + + with pytest.raises(PermissionDenied, match="approved API access"): + run(go()) + + @pytest.mark.parametrize("code, kind", [ + ("ORDER_REJECTED_INSUFFICIENT_FUNDS", InsufficientFunds), ("ORDER_REJECTED_MARKET_HALTED", MarketHalted), + ("ORDER_INVALID_INVALID_PRICE", InvalidOrder), ("ORDER_REJECTED_CROSSED_SELF", OrderRejected), + ("ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY", OrderRejected), + ]) + def test_a_refused_order_is_typed(self, code, kind): + venue = Venue(**{"POST /v3/orders/": error(code)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_order(request()) + + with pytest.raises(kind) as caught: + run(go()) + assert code in str(caught.value) + + def test_create_orders_returns_one_result_each(self): + answers = iter([httpx.Response(200, json=S["create"]), error("ORDER_REJECTED_INSUFFICIENT_FUNDS")]) + venue = Venue(**{"POST /v3/orders/": lambda req: next(answers)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_orders([request(), request(), request(post_only=True)]) + + placed, refused, invalid = run(go()) + assert placed.status == OrderStatus.CLOSED + assert isinstance(refused, InsufficientFunds) and isinstance(invalid, InvalidOrder) + assert len(venue.sent("POST", "/v3/orders/")) == 2 + + def test_cancel_reads_the_order_back(self): + listed = iter([{"orders": [resting(quantity_unfilled_user_currency=0)]}]) + venue = Venue(**{f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], "GET /v3/orders/": lambda req: next(listed)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_order(ORDER_ID) + + order = run(go()) + assert order.status == OrderStatus.CANCELED + assert venue.sent("GET", "/v3/orders/")[0].url.params.get_list("id") == [ORDER_ID] + + def test_a_cancel_not_applied_yet_is_pending(self): + venue = Venue(**{f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], "GET /v3/orders/": {"orders": [resting()]}}) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_order(ORDER_ID) + + assert run(go()).status == OrderStatus.PENDING_CANCEL + + def test_a_refused_cancel_reads_back_and_an_unknown_one_is_not_found(self): + filled = resting(state="filled", quantity_filled_user_currency=100000, quantity_unfilled_user_currency=0) + venue = Venue(**{ + f"DELETE /v3/orders/{ORDER_ID}/": error("ORDER_CANCEL_REJECTED_TOO_LATE"), + "GET /v3/orders/": {"orders": [filled]}, + "DELETE /v3/orders/9/": error("ORDER_CANCEL_REJECTED_NOT_FOUND", 404), + }) + + async def go(): + async with adapter(venue) as smk: + too_late = await smk.cancel_order(ORDER_ID) + with pytest.raises(OrderNotFound): + await smk.cancel_order("9") + return too_late + + assert run(go()).status == OrderStatus.CLOSED + + def test_cancel_all_is_one_request_once_a_minute_then_order_by_order(self): + venue = Venue(**{ + "DELETE /v3/orders/": S["cancel_all"], + "GET /v3/orders/": lambda req: {"orders": [resting()] if req.url.params.get_list("state") else + [resting(quantity_unfilled_user_currency=0)]}, + f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], + }) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_all_orders(market_id="smarkets:128939"), await smk.cancel_all_orders() + + assert run(go()) == (None, 1) + [everything] = venue.sent("DELETE", "/v3/orders/") + assert everything.url.params["market_id"] == "128939" + assert len(venue.sent("DELETE", f"/v3/orders/{ORDER_ID}/")) == 1 + + def test_cancel_all_on_a_contract_and_after_a_429_goes_order_by_order(self): + venue = Venue(**{ + "DELETE /v3/orders/": error("RATE_LIMIT_EXCEEDED", 429), + "GET /v3/orders/": lambda req: {"orders": [resting()] if req.url.params.get_list("state") else + [resting(quantity_unfilled_user_currency=0)]}, + f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], + }) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_all_orders(), await smk.cancel_all_orders(market_id=CONTRACT) + + assert run(go()) == (1, 1) + assert len(venue.sent("DELETE", "/v3/orders/")) == 1 # not retried + on_contract = [r for r in venue.sent("GET", "/v3/orders/") if r.url.params.get("contract_id")] + assert on_contract and on_contract[0].url.params["contract_id"] == "123456" + + def test_open_orders_page_by_id(self): + pages = iter([{"orders": [resting()], "pagination": {"next_page": "?limit=100&pagination_last_id=1518455672861"}}, + {"orders": [resting(id="1518455672862"), resting(id="3", quantity_unfilled_user_currency=0)], + "pagination": {"next_page": None}}]) + venue = Venue(**{"GET /v3/orders/": lambda req: next(pages)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_open_orders(market_id=CONTRACT) + + orders = run(go()) + assert [o.id for o in orders] == [ORDER_ID, "1518455672862"] + first, second = venue.sent("GET", "/v3/orders/") + assert first.url.params.get_list("state") == ["created", "partial"] + assert first.url.params["market_id"] == "128939" and first.url.params["contract_id"] == "123456" + assert second.url.params["pagination_last_id"] == ORDER_ID + + def test_fetch_orders_by_status_with_a_cursor(self): + venue = Venue(**{"GET /v3/orders/": {**S["orders"], "orders": [resting(state="filled", + quantity_filled_user_currency=100000, quantity_unfilled_user_currency=0)]}}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_orders(status="closed", limit=5) + + page = run(go()) + assert [o.status for o in page] == [OrderStatus.CLOSED] and page.next_cursor == "810214202189041" + sent = venue.sent("GET", "/v3/orders/")[0] + assert sent.url.params.get_list("state") == ["filled", "settled"] and sent.url.params["limit"] == "5" + + def test_an_order_not_listed_is_read_by_id_or_not_found(self): + venue = Venue(**{"GET /v3/orders/": {"orders": []}, f"GET /v3/orders/{ORDER_ID}/": S["order"], + "GET /v3/orders/9/": error("ORDER_NOT_FOUND", 404)}) + + async def go(): + async with adapter(venue) as smk: + found = await smk.fetch_order(ORDER_ID) + with pytest.raises(OrderNotFound): + await smk.fetch_order("9") + return found + + assert run(go()).id == ORDER_ID + + def test_my_trades_are_the_statements_executions(self): + execution = row(S["activity"]["account_activity"][0], source="order.execute") + other = row(execution, seq=1, contract_id="999") + venue = Venue(**{ + "GET /v3/accounts/activity/": {**S["activity"], "account_activity": [execution, other], + "pagination": {"next_page": "?limit=500&pagination_last_seq=1&pagination_last_subseq=123242"}}, + "GET /v3/accounts/": S["account"], + }) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_my_trades(market_id=CONTRACT) + + fills = run(go()) + assert [f.id for f in fills] == ["11423-123242"] and fills[0].fee_currency == "GBP" + assert fills.next_cursor == "1:123242" + sent = venue.sent("GET", "/v3/accounts/activity/")[0] + assert sent.url.params.get_list("source") == ["order.execute"] and sent.url.params["market_id"] == "128939" + + def test_positions_and_settlements_read_the_orders(self): + def orders(req): + states = req.url.params.get_list("state") + if states == ["settled"]: + return {"orders": [resting(state="settled", outcome="loser", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0)]} + return {"orders": [resting(state="filled", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0)]} + + venue = Venue(**{"GET /v3/orders/": orders}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_positions(market_id=CONTRACT), await smk.fetch_settlements() + + [position], settlements = run(go()) + assert position.contracts == Decimal("10") and position.side == PositionSide.LONG + assert settlements[0].result == "no" and settlements[0].won is False and settlements[0].pnl == Decimal("-5") + current = venue.sent("GET", "/v3/orders/")[0] + assert current.url.params.get_list("state") == ["created", "partial", "filled"] + + def test_the_fee_is_the_accounts_commission_on_what_the_bet_wins(self): + venue = Venue(**{"GET /v3/accounts/": {"account": {**S["account"]["account"], "commission_type": "0.03"}}}) + + async def go(): + async with adapter(venue) as smk: + return (await smk.fetch_fee_estimate(CONTRACT, Side.BUY, Decimal("0.4"), Decimal("100")), + await smk.fetch_fee_estimate(CONTRACT, Side.SELL, Decimal("0.4"), Decimal("100"))) + + buy, sell = run(go()) + assert buy.taker_fee == Decimal("1.8") and sell.taker_fee == Decimal("1.2") and buy.maker_fee == buy.taker_fee + assert buy.currency == "GBP" and len(venue.sent("GET", "/v3/accounts/")) == 1 + + +class TestWiring: + def test_credentials_are_needed(self): + with pytest.raises(CredentialsMissing): + SmarketsTrading(None) + + def test_the_client_the_engine_and_serve_build_it(self): + import synpath + from synpath.engine.__main__ import ADAPTERS + from synpath.server.serve import VENUES + + creds = SmarketsCredentials(username="u", password="p") + trading = synpath.Client({"smarkets": creds}).trading("smarkets") + try: + assert isinstance(trading, SmarketsTrading) and trading.has["edit_order"] is False + finally: + run(trading.close()) + assert ADAPTERS["smarkets"] == ("..trading.smarkets", "SmarketsTrading") + assert "smarkets" in VENUES and synpath.SmarketsTrading is SmarketsTrading + + def test_the_engine_streams_share_the_adapters_session(self): + from synpath.engine.feeds import default_streams + + creds = SmarketsCredentials(username="u", password="p") + trading = SmarketsTrading(creds) + try: + streams = default_streams({"smarkets": trading}, {"smarkets": creds})["smarkets"] + assert streams.private is None and streams.market.session is trading.session + finally: + run(trading.close()) + + def test_doctor_names_the_login(self, tmp_path, monkeypatch, capsys): + from synpath.trading.__main__ import doctor + + for name in ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"): + monkeypatch.delenv(name, raising=False) + env = tmp_path / ".env" + env.write_text("SMARKETS_USERNAME=jbloggs\nSMARKETS_PASSWORD=secret-pw-123\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + assert "user jbloggs" in out and "secret-pw-123" not in out From 29e4b4b6e1822346f045bc75191c9187e43f9c49 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 14/19] feat: myriad-amm Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/api.md | 4 +- docs/capabilities.md | 8 +- docs/trading.md | 40 +- synpath/trading/credentials.py | 2 +- synpath/trading/myriad.py | 676 +++++++++++++++++- synpath/trading/myriad_signing.py | 115 ++- tests/samples/myriad_quote_abstract_buy.json | 1 + tests/samples/myriad_quote_abstract_sell.json | 1 + tests/test_myriad_amm_trading.py | 531 ++++++++++++++ tests/test_myriad_trading.py | 20 +- 11 files changed, 1351 insertions(+), 51 deletions(-) create mode 100644 tests/samples/myriad_quote_abstract_buy.json create mode 100644 tests/samples/myriad_quote_abstract_sell.json create mode 100644 tests/test_myriad_amm_trading.py diff --git a/README.md b/README.md index 40c5d11..6116c5e 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [Matchbook](https://www.matchbook.com) | `matchbook` | ✓ no account | ✓ your own login | polled from REST | one book per runner, back and lay read as YES | | | [ProphetX](https://www.prophetx.co) | `prophetx` | ✓ sports (Trading API key pair, or a Market Data API key) | ✓ | WebSocket (Trading API key pair) | one book per line, both sides read it | | | [Azuro](https://azuro.org) | `azuro` | ✓ sports, no key | ✓ bets with a worst price | WebSocket (no key) | no book: the pool's quoted odds, one level a side sized by its limits | -| | [Myriad](https://myriad.markets) | `myriad` | ✓ order-book and AMM markets, sports | ✓ order book | WebSocket (no key) | one book per market, both sides read it; AMM books built from the pool | +| | [Myriad](https://myriad.markets) | `myriad` | ✓ order-book and AMM markets, sports | ✓ order book; AMM as market orders | WebSocket (no key) | one book per market, both sides read it; AMM books built from the pool | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). @@ -103,7 +103,7 @@ ProphetX, a US peer-to-peer sports exchange, is read the way SX Bet is: each lin Azuro is an onchain sports betting protocol: a shared liquidity pool takes every bet at the odds it quotes, so there is no order book and nothing rests. A two-outcome condition ("Total Goals Over/Under 2.5") is one yes/no market, `azuro:`, with the lower outcome id as YES; a condition with three or more outcomes (1X2, correct score) is one market per outcome, `azuro:-`, where only YES can be bought. The game is the event. Prices are `1 / odds`. Ids are the same on every chain; `Azuro(chain=...)` picks the pool read (Polygon USDT by default, Base WETH). Bets are EIP-712 messages signed by your wallet and placed on chain by Azuro's relayer. Azuro's bet API and streams are refused from some regions. See [capabilities](docs/capabilities.md#azuro). -Myriad runs order-book markets on BNB Chain and AMM (pool) markets on BNB Chain, Abstract and Linea behind one API. A market id carries its network, and `amm` for a pool market, since the two kinds number their markets separately: `myriad:56-2385` is an order-book market, `myriad:56-amm-2385` an unrelated pool market. Order entry is on the order book; a pool trade is an on-chain transaction this library does not send. See [capabilities](docs/capabilities.md#myriad). +Myriad runs order-book markets on BNB Chain and AMM (pool) markets on BNB Chain, Abstract and Linea behind one API. A market id carries its network, and `amm` for a pool market, since the two kinds number their markets separately: `myriad:56-2385` is an order-book market, `myriad:56-amm-2385` an unrelated pool market. Order entry covers both: signed orders on the order book, and market orders against a pool, each sent as one on-chain transaction from the wallet. See [capabilities](docs/capabilities.md#myriad). ## Why Synpath diff --git a/docs/api.md b/docs/api.md index 4de4820..6ecf1fe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -196,7 +196,9 @@ Myriad needs no key (an API key and secret raise its rate limit). It takes `network` (56 BNB Chain, 2741 Abstract, 59144 Linea) on `fetch_markets` and `fetch_events`, and adds `fetch_event(id)`, `fetch_topics()`, `fetch_tags(type)` and `raw_market(id)`. Any id also takes the market's slug. See -[Myriad](capabilities.md#myriad). +[Myriad](capabilities.md#myriad). Both kinds trade through `MyriadTrading`: +order-book markets with signed orders, AMM markets as market orders sent on +chain ([Trading](trading.md#myriad)). ```python myriad = synpath.Myriad() # no key needed diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..f8c49fa 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -432,6 +432,12 @@ price, from the market's own table of basis points by cent (3 bps at 1c, 180 at 50c; `bps_by_price`); a maker pays nothing. A pool charges a share of the value traded on each side, set per market (`share_of_value`), often zero. +Order entry covers both kinds. Order-book orders are signed EIP-712 orders; +an AMM market trades as market orders only (`market`, `ioc`, `fok` at a worst +price), each one transaction from the wallet to the network's pool contract, +its size and on-chain bound worked out from the pool, and its fill read from +the receipt ([Trading](trading.md#myriad)). + No key for market data, at 30 requests per 10 seconds; an API key and its secret (`Myriad(api_key=..., api_secret=...)`) raise that to 200 a second, and then every request is HMAC-signed, since the venue refuses a bare key. Check @@ -529,7 +535,7 @@ onboarded firms. | Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; AMM markets take `market`, `ioc` and `fok` only, each one on-chain transaction bounded by the worst average price | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | | `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | | `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | | `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..6aa5c6a 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -100,7 +100,7 @@ The variables each venue reads: | Hyperliquid | `HYPERLIQUID_PRIVATE_KEY` (an API wallet's key is safest: it trades but cannot withdraw), `HYPERLIQUID_ACCOUNT_ADDRESS` (the account an API wallet trades for; not needed with the account's own key), `HYPERLIQUID_TESTNET` (`1` for the test network) | | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | -| Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from) | +| Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from and AMM trades are sent to) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | @@ -774,8 +774,9 @@ some regions with a 503; check where Azuro may be used before betting. ## Myriad `MyriadTrading`, built to Myriad's API reference, for its order book on BNB -Chain. Order hashes and signatures are checked against an independent -EIP-712 encoder and fixed known answers. +Chain and its AMM (pool) markets on BNB Chain, Abstract and Linea. Order +hashes and signatures are checked against an independent EIP-712 encoder and +fixed known answers; pool calls against the venue's own quote calldata. Every order is the venue's nine-field EIP-712 `Order` (trader, market, outcome, side, amount, price, minimum fill, nonce, expiration), signed by the @@ -822,9 +823,36 @@ from the wallet's public tape with their orders and fees, and share balances book and AMM alike, netted per binary market on the YES leg. The balance is the wallet's USD1 on BNB Chain, less what open buy orders reserve. -AMM markets trade through on-chain contract calls (the venue's quote route -returns the calldata), one per trade with gas, on BNB Chain, Abstract and -Linea; they are not traded here, and an order for one is refused. +AMM markets (`myriad:56-amm-120473`, one outcome of a larger pool +`myriad:2741-amm-412-3`) trade as market orders: each is one transaction from +the wallet to the network's `PredictionMarket` contract, which pays its gas in +the chain's coin. There is nothing to rest, so a pool order is `market`, +`ioc` or `fok` with a worst price; a `gtc` or `gtd` limit and post-only are +refused. The worst price bounds the average price, fees in: the order is sized +from the pool read on chain, `market` and `ioc` trading the most shares up to +the quantity whose average is within it, `fok` the whole quantity or nothing +(an order that fits nothing is `canceled` and sends nothing). The bound goes on +chain as the contract's own guard, the minimum shares a buy's value must buy +(`value / worst`) or the most shares a sell may burn for its payout, so the +transaction reverts rather than fill worse if the pool moves first. YES and NO +are outcomes 0 and 1 of a two-outcome pool, as on the order book; on one +outcome of a larger pool YES is that outcome, and NO, the rest of the pool, is +not one trade. A sell (`reduce_only`) checks the shares held, a buy the +token's balance and its approval: `approve_amm(market_id)` approves the +market's token (USD1 or USDT, USDC.e, USDC) to that network's contract once. +`create_order` waits for the receipt (`params={"wait": False}` returns it +`pending`, its id the transaction hash, for `fetch_order` to read again) and +reports the fill from the receipt's `MarketActionTx`: shares, average price and +the fee, a share of the value set per market (`share_of_value`; on a buy it +comes out of the value spent, on a sell it is on top of the payout). The price +is the value over the shares, fees in, as the account's history gives it, and +the fill's id is the history's, so `fetch_my_trades`, which reads order-book +and pool trades alike, shows the same trade once. A reverted trade comes back +`rejected`. `plan_amm_order(request)` sizes an order and builds its call +without sending it, and `redeem_amm(market_id)` claims a resolved market's +winnings (or a voided one's shares). Nodes default to public ones; +`MYRIAD_RPC_URL` sets BNB Chain's and `rpc_urls={2741: ..., 59144: ...}` the +others. Check Myriad's terms for where it may be used before trading. diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..9f42e38 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -358,7 +358,7 @@ class MyriadCredentials: account_address: str | None = None """The wallet's address, when no private key names it. Public.""" rpc_url: str | None = None - """A BNB Chain node for the balance read. A public one when unset.""" + """A BNB Chain node for the balance read and AMM trades there. A public one when unset.""" _public = ("api_key", "account_address", "rpc_url") __repr__ = _redacted_repr diff --git a/synpath/trading/myriad.py b/synpath/trading/myriad.py index af273e2..4312ff2 100644 --- a/synpath/trading/myriad.py +++ b/synpath/trading/myriad.py @@ -32,25 +32,39 @@ **Fees** are taker-only: a share of the notional by price, from the market's table (`bps_by_price`). Makers pay nothing. -AMM markets trade on-chain, one contract call per trade with gas, through the -venue's quote route; that is not built here, and an order for one is refused. -Approvals are `approval_calls()`, for the wallet to send once; split, merge -and redeem calldata come from `position_calldata()`. +**AMM markets** (`myriad:56-amm-120473`, one outcome of many +`myriad:2741-amm-412-3`) trade against the pool, one contract call per trade +that the wallet sends and pays gas for. They are taker-only: a `market`, +`ioc` or `fok` order at a worst price, which bounds the average price paid +(fees in) or received. The order is sized from the pool read on chain: `ioc` +and `market` trade the most shares up to the quantity whose average is +within the worst price, `fok` the whole quantity or nothing. The bound goes +on chain as the contract's minimum shares out (a buy) or maximum shares in +(a sell), so the transaction reverts rather than fill worse. The fill (shares, +value, fee) is read from the receipt's `MarketActionTx`. A buy needs the +market's token approved to the network's `PredictionMarket` once +(`approve_amm()`); `redeem_amm()` claims a resolved market's winnings. + +Order-book approvals are `approval_calls()`, for the wallet to send once; +split, merge and redeem calldata come from `position_calldata()`. """ from __future__ import annotations import asyncio import json +import math import re +import secrets import time -from decimal import Decimal +from dataclasses import dataclass, field +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from typing import Any from urllib.parse import urlencode from .. import ids from ..base import AsyncHttpClient, Capability from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound -from ..myriad import ORDER_BOOK_NETWORK, Ref, iso_ms, native_id, parse_id +from ..myriad import ORDER_BOOK_NETWORK, Ref, _after_buy, _after_sell, amm_fees, is_amm, iso_ms, native_id, parse_id from ..types import Page from . import myriad_signing as sig from .base import TradingExchange @@ -60,12 +74,16 @@ ) from .limiter import BudgetLimiter, Priority from .polymarket import to_yes_leg, wire_leg -from .polymarket_signing import WalletSigner +from .polymarket_signing import MAX_UINT256, WalletSigner from .types import ( Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, SettlementState, Side, TimeInForce, VENUE_ORDER_TYPES, ) +AMM_MARGIN = 1e-6 +"""How far inside the worst price an AMM order is sized, so that rounding +never puts the expected trade on the bound itself.""" + VENUE = "myriad" API_URL = "https://api-v2.myriadprotocol.com" RPC_URL = "https://bsc-dataseed.bnbchain.org" @@ -238,10 +256,14 @@ def order_of_update(data: dict[str, Any], *, account: Account | None = None) -> ) -def fill_of_action(row: dict[str, Any], *, account: Account | None = None) -> Fill | None: +def fill_of_action(row: dict[str, Any], *, account: Account | None = None, amm: bool = False, + many: bool = False) -> Fill | None: """One trade in the account's history (`/users/:address/events`) as a fill on the YES leg, or `None` for anything else (claims, liquidity). The - history names neither the order nor the fee.""" + history names neither the order nor the fee. A pool trade (`amm`) is on + the pool's market id; on a pool of more than two outcomes (`many`) each + outcome is its own market, YES that outcome. A pool trade's price is its + value over its shares, fees in.""" action = str(row.get("action") or "") if action not in ("buy", "sell"): return None @@ -249,14 +271,14 @@ def fill_of_action(row: dict[str, Any], *, account: Account | None = None) -> Fi if shares <= 0: return None outcome = int(row.get("outcomeId") or 0) - side, price = to_yes_leg(_outcome(outcome), action.upper(), value / shares) + side, price = to_yes_leg("yes" if many else _outcome(outcome), action.upper(), value / shares) stamp = iso_ms(row.get("timestamp")) or 0 return Fill( id=f"{row.get('txId') or stamp}:{outcome}:{action}", order_id="", venue=VENUE, account=account, - market_id=market_id_of(row.get("networkId"), row.get("marketId")), + market_id=market_id_of(row.get("networkId"), row.get("marketId"), amm=amm, outcome=outcome if many else None), side=side, price=price or Decimal("0"), quantity=shares, @@ -317,10 +339,13 @@ def fills_of_trade(data: dict[str, Any], *, address: str, account: Account | Non RESOLVED = ("won", "lost", "claimed", "voided") -def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: +def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None, + many: set[tuple[int, int]] | None = None) -> list[Position]: """The account's portfolio rows (one per market and outcome) as positions: a binary market's two outcomes netted on the YES leg with both kept, and - an outcome of a many-outcome AMM market as its own market.""" + an outcome of a many-outcome AMM market as its own market. `many` names + the pools (`(network, market)`) known to have more than two outcomes; a + row on an outcome above 1 shows it too.""" by_market: dict[tuple[int, int, bool], list[dict[str, Any]]] = {} for row in rows: if row.get("marketId") is None: @@ -329,8 +354,8 @@ def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None) by_market.setdefault(key, []).append(row) out: list[Position] = [] for (network, market, amm), group in by_market.items(): - many = amm and any(int(r.get("outcomeId") or 0) > 1 for r in group) - if many: + several = amm and ((network, market) in (many or set()) or any(int(r.get("outcomeId") or 0) > 1 for r in group)) + if several: for row in group: out.append(_position(network, market, amm, {int(row.get("outcomeId") or 0): row}, account=account, outcome=int(row.get("outcomeId") or 0))) @@ -397,12 +422,245 @@ def error_of(exc: ExchangeError, *, client_order_id: str | None = None) -> Excha return OrderRejected(f"{VENUE}: {text}", reason=code or None, info=body, body=exc.body, status=exc.status) +# --------------------------------------------------------------------------- +# AMM orders +# --------------------------------------------------------------------------- + +def _bisect(rising: Any, target: float, low: float, high: float) -> tuple[float, float]: + """`(low, high)` bracketing where the increasing `rising` reaches `target`.""" + for _ in range(200): + middle = (low + high) / 2 + if middle in (low, high): + break + if rising(middle) < target: + low = middle + else: + high = middle + return low, high + + +def amm_buy_shares(pools: list[float], index: int, value: float, fee: float) -> float: + """The shares of outcome `index` that `value` buys, fees taken out of it.""" + if value <= 0: + return 0.0 + return _after_buy(pools, index, value * (1 - fee))[1] + + +def amm_buy_cost(pools: list[float], index: int, shares: float, fee: float) -> float: + """The value, fees in, that buys `shares` of outcome `index`.""" + if shares <= 0: + return 0.0 + high = 1.0 + for _ in range(200): + if amm_buy_shares(pools, index, high, fee) >= shares: + break + high *= 2 + return _bisect(lambda v: amm_buy_shares(pools, index, v, fee), shares, 0.0, high)[1] + + +def _sell_ceiling(pools: list[float], index: int) -> float: + return min(n for j, n in enumerate(pools) if j != index) + + +def amm_sell_shares(pools: list[float], index: int, value: float, fee: float) -> float: + """The shares of outcome `index` a payout of `value`, after fees, takes: + the pool pays out `value / (1 - fee)` and keeps the fee.""" + if value <= 0: + return 0.0 + gross = value / (1 - fee) + if gross >= _sell_ceiling(pools, index): + return math.inf + return _after_sell(pools, index, gross)[1] + + +def amm_sell_value(pools: list[float], index: int, shares: float, fee: float) -> float: + """What selling `shares` of outcome `index` pays out, after fees.""" + if shares <= 0: + return 0.0 + ceiling = _sell_ceiling(pools, index) * (1 - fee) * (1 - 1e-12) + return _bisect(lambda v: amm_sell_shares(pools, index, v, fee), shares, 0.0, ceiling)[0] + + +def _units(value: float | Decimal, decimals: int, rounding: str) -> int: + return int((Decimal(str(value)) * (Decimal(10) ** decimals)).to_integral_value(rounding=rounding)) + + +@dataclass +class AmmPlan: + """One pool trade, sized and bounded before it is sent. Amounts in tokens + unless named `_units` (the token's smallest unit); prices are the + outcome's own, fees in.""" + + network: int + market: int + outcome: int + """The outcome traded.""" + action: str + """`buy` or `sell`.""" + worst: Decimal + """The worst average price accepted, on the outcome traded.""" + requested: Decimal + """The shares asked for.""" + shares: Decimal = Decimal("0") + """The shares expected, from the pool as read.""" + value: Decimal = Decimal("0") + """Spent, fees in (a buy), or paid out, after fees (a sell).""" + average: Decimal | None = None + fee: Decimal = Decimal("0") + value_units: int = 0 + bound_units: int = 0 + """The on-chain bound: the minimum shares out (a buy), the maximum shares in (a sell).""" + decimals: int = 18 + contract: str = "" + token: str = "" + symbol: str = "" + calldata: str = "" + reason: str | None = None + """Why nothing is traded, when the pool fills nothing within the worst price.""" + info: dict[str, Any] = field(default_factory=dict) + + @property + def fills(self) -> bool: + return self.reason is None + + @property + def bound(self) -> Decimal: + return Decimal(self.bound_units) / (Decimal(10) ** self.decimals) + + +def plan_amm_trade( + pools: list[float], *, index: int, action: str, shares: Decimal, worst: Decimal, buy_fee: float, sell_fee: float, + decimals: int, partial: bool, held: Decimal | None = None, +) -> dict[str, Any]: + """Size a pool trade and its on-chain bound. `worst` bounds the average + price, fees in: a buy spends `value` for at least `value / worst` shares, + a sell is paid `value` for at most `value / worst`. `partial` (`ioc`) + trades the most shares up to `shares` whose average is within `worst`; + otherwise all of them or nothing. Returns the plan's numbers, or a + `reason` when nothing trades.""" + want, bound = float(shares), float(worst) + if action == "buy": + def average(q: float) -> float: + return amm_buy_cost(pools, index, q, buy_fee) / q + within = lambda q: average(q) <= bound * (1 - AMM_MARGIN) # noqa: E731 + else: + def average(q: float) -> float: + return amm_sell_value(pools, index, q, sell_fee) / q + within = lambda q: average(q) >= bound * (1 + AMM_MARGIN) # noqa: E731 + size = want + if not within(size): + if not partial: + return {"reason": f"the pool fills {shares} at an average of {average(want):.6f}, worse than {worst}"} + low, high = 0.0, want + for _ in range(200): + middle = (low + high) / 2 + if middle in (low, high): + break + low, high = (middle, high) if within(middle) else (low, middle) + size = low + if size <= 0 or not within(size) or size * 10**decimals < 1: + return {"reason": f"the pool fills nothing at {worst} or better"} + if action == "buy": + value_units = _units(amm_buy_cost(pools, index, size, buy_fee), decimals, ROUND_FLOOR) + value = Decimal(value_units) / (Decimal(10) ** decimals) + expected = amm_buy_shares(pools, index, float(value), buy_fee) + bound_units = int((Decimal(value_units) / worst).to_integral_value(rounding=ROUND_CEILING)) + fee = value * Decimal(str(buy_fee)) + else: + value_units = _units(amm_sell_value(pools, index, size, sell_fee), decimals, ROUND_FLOOR) + value = Decimal(value_units) / (Decimal(10) ** decimals) + expected = amm_sell_shares(pools, index, float(value), sell_fee) + bound_units = int((Decimal(value_units) / worst).to_integral_value(rounding=ROUND_FLOOR)) + if held is not None: + bound_units = min(bound_units, _units(held, decimals, ROUND_FLOOR)) + fee = value / (1 - Decimal(str(sell_fee))) - value if sell_fee else Decimal("0") + if value_units <= 0 or expected <= 0: + return {"reason": f"the pool fills nothing at {worst} or better"} + shares_out = Decimal(str(expected)) + return {"value_units": value_units, "bound_units": bound_units, "value": value, "shares": shares_out, + "average": value / shares_out, "fee": fee} + + +def amm_time_in_force(request: OrderRequest) -> bool: + """Whether an AMM order may fill in part (`market`, `ioc`) or must fill + whole (`fok`). A pool trade fills at once or not at all, so a resting + order is refused.""" + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder(f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; " + f"submit it through the engine") + tif = request.time_in_force + resting = request.post_only or (request.type == OrderType.LIMIT and tif not in (TimeInForce.IOC, TimeInForce.FOK)) + if resting: + what = "post-only" if request.post_only else f"a {tif.value} limit" + raise InvalidOrder(f"{VENUE}: AMM markets are taker-only -- a pool trade fills at once or not at all, so " + f"{what} cannot rest; send a market, ioc or fok order with the worst price") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- an AMM order is sent with the worst average price " + f"you give") + if not Decimal("0") < D(request.price) < Decimal("1"): + raise InvalidOrder(f"{VENUE}: price {request.price} is outside (0, 1)") + return tif != TimeInForce.FOK + + +def amm_leg(request: OrderRequest, ref: Ref, outcomes: int) -> tuple[int, str, Decimal]: + """`(outcome, action, worst)`: the pool outcome an order trades, `buy` + or `sell`, and its worst price on that outcome. On a two-outcome pool YES + is outcome 0 and NO outcome 1, as on the order book; on one outcome of + many, YES is that outcome and NO (the rest of the pool) is not one trade.""" + side, venue_side = wire_leg(request) + action = "buy" if venue_side == "BUY" else "sell" + if ref.outcome is not None: + if ref.outcome >= outcomes: + raise InvalidOrder(f"{VENUE}: {ref.native} names outcome {ref.outcome}; the pool has {outcomes}") + if side == "no": + hint = "pass reduce_only to sell the outcome held" if action == "buy" else "buy the other outcomes' markets" + raise InvalidOrder(f"{VENUE}: NO on {ref.native} is the rest of the pool, not one trade; {hint}") + return ref.outcome, action, D(request.price) + if outcomes != 2: + raise InvalidOrder(f"{VENUE}: {ref.native} has {outcomes} outcomes; trade one of them, " + f"{ref.native}-") + return (0 if side == "yes" else 1), action, (D(request.price) if side == "yes" else Decimal("1") - D(request.price)) + + +def amm_fill(action: dict[str, Any], *, tx_hash: str, plan: AmmPlan, many: bool, order_id: str, + client_order_id: str | None = None, account: Account | None = None) -> Fill: + """A decoded `MarketActionTx` as this account's fill, on the YES leg and + with the same id the account's history gives the trade. The price is the + value over the shares, fees in, as in the history; `fee` is that fee.""" + scale = Decimal(10) ** plan.decimals + shares, value = Decimal(int(action["shares"])) / scale, Decimal(int(action["value"])) / scale + outcome = int(action["outcome"]) + buying = int(action["action"]) == sig.AMM_BUY + rate = Decimal(str(plan.info.get("buy_fee" if buying else "sell_fee") or 0)) + fee = value * rate if buying else (value / (1 - rate) - value if rate else Decimal("0")) + side, price = to_yes_leg("yes" if many or outcome == 0 else "no", "BUY" if buying else "SELL", + value / shares if shares > 0 else None) + return Fill( + id=f"{tx_hash}:{outcome}:{'buy' if buying else 'sell'}", + order_id=order_id, + client_order_id=client_order_id, + venue=VENUE, + account=account, + market_id=market_id_of(plan.network, plan.market, amm=True, outcome=outcome if many else None), + side=side, + price=price or Decimal("0"), + quantity=shares, + fee=fee, + fee_currency=plan.symbol or None, + liquidity=Liquidity.TAKER, + settlement=SettlementState.CONFIRMED, + timestamp=int(action.get("timestamp") or 0) * 1000, + info={"tx_hash": tx_hash, "outcome": outcome, "value": str(value), "fee_in_price": True}, + ) + + # --------------------------------------------------------------------------- # Adapter # --------------------------------------------------------------------------- class MyriadTrading(TradingExchange): - """Myriad order entry, on the order book. + """Myriad order entry: signed orders on the order book, and market + orders against an AMM market's pool, sent as the wallet's transactions. ```python from synpath.trading.credentials import load_credentials, require @@ -416,8 +674,11 @@ class MyriadTrading(TradingExchange): id = VENUE name = "Myriad" has: dict[str, Capability] = { + # Order books: signed orders. AMM markets: market, ioc or fok only, + # one on-chain transaction each. "create_order": True, # Up to 200 a request; the venue checks collateral at match time. + # AMM orders go one transaction at a time. "create_orders": True, "cancel_order": True, # Up to 200 a request. @@ -455,6 +716,7 @@ def __init__( timeout: float = 30.0, client: Any = None, rpc_client: Any = None, + rpc_urls: dict[int, str] | None = None, ): import httpx @@ -471,6 +733,9 @@ def __init__( shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) self.http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE) self.rpc_url = rpc_url or credentials.rpc_url or RPC_URL + self.rpc_urls = {**sig.AMM_RPC_URLS, **(rpc_urls or {}), ORDER_BOOK_NETWORK: self.rpc_url} + """A node per network (BNB Chain 56, Abstract 2741, Linea 59144) for + AMM trades; public ones unless given.""" self.rpc = rpc_client or httpx.AsyncClient(timeout=20, follow_redirects=True) self._own_catalog = catalog is None if catalog is None: @@ -483,6 +748,10 @@ def __init__( self._signed: dict[str, dict[str, Any]] = {} """Order hash -> the signed `{order, signature}` a cancel repeats.""" self._session: dict[str, Any] | None = None + self._amm_orders: dict[str, tuple[Order, AmmPlan, bool]] = {} + """Transaction hash -> an AMM order sent here, its plan, and whether its pool has many outcomes.""" + self._outcome_counts: dict[tuple[int, int], int] = {} + self._tx_lock = asyncio.Lock() @property def keyed(self) -> bool: @@ -582,15 +851,20 @@ async def subscription_token(self, channel: str) -> str: raise AuthenticationError(f"{VENUE}: no subscription token for {channel}") return str(raw["token"]) - async def _ob_ref(self, market_id: str) -> Ref: - """An order-book market's parts; an AMM market is refused.""" + async def _ref(self, market_id: str) -> Ref: + """A market's parts, either kind; a slug is looked up.""" native = ids.native(VENUE, market_id) ref = parse_id(native) if ref is None: ref, _ = await asyncio.to_thread(self.catalog._resolve, native) + return ref + + async def _ob_ref(self, market_id: str) -> Ref: + """An order-book market's parts; an AMM market is refused.""" + ref = await self._ref(market_id) if ref.amm: - raise InvalidOrder(f"{VENUE}: {ref.native} is an AMM market, traded on-chain through the venue's quote " - f"route; this adapter places order-book orders only") + raise InvalidOrder(f"{VENUE}: {ref.native} is an AMM market: it has no orders, cancels or calldata " + f"routes; trade it with a market, ioc or fok order") if ref.network != ORDER_BOOK_NETWORK: raise InvalidOrder(f"{VENUE}: order books run on BNB Chain (56) only, not {ref.network}") return ref @@ -646,8 +920,13 @@ def _order(self, request: OrderRequest, *, ref: Ref, order_id: str, contracts: D ) async def create_order(self, request: OrderRequest) -> Order: - """Sign and place one order. The venue answers once the order is on - the book; fills follow from the matcher.""" + """Sign and place one order. On an order book the venue answers once + the order is on the book, and fills follow from the matcher. On an + AMM market the trade is a transaction from the wallet; see + `create_amm_order`.""" + ref = await self._ref(request.market_id) + if ref.amm: + return await self.create_amm_order(request) time_in_force_of(request) signer = self._need_signer("placing an order") ref = await self._ob_ref(request.market_id) @@ -676,6 +955,9 @@ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exce prepared: list[tuple[int, OrderRequest, dict[str, Any], str, Decimal, Ref]] = [] for i, request in enumerate(requests): try: + if (await self._ref(request.market_id)).amm: + results[i] = await self.create_amm_order(request) + continue time_in_force_of(request) signer = self._need_signer("placing an order") ref = await self._ob_ref(request.market_id) @@ -707,6 +989,9 @@ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exce async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: """Cancel one order (repeating it and its signature) and read it back.""" + if order_id.lower() in self._amm_orders: + raise InvalidOrder(f"{VENUE}: {order_id} is an AMM trade, one transaction filled or reverted at once; " + f"there is nothing to cancel") entry = await self._signed_entry(order_id) await self._call("DELETE", f"/orders/{order_id}", body={**entry, "network_id": ORDER_BOOK_NETWORK}, kind="write", priority=Priority.HIGH) @@ -790,6 +1075,15 @@ async def edit_order(self, request: EditRequest, *, current: Order | None = None return order.model_copy(update={"queue_priority_preserved": False}) async def fetch_order(self, order_id: str) -> Order: + """An order by its hash; an AMM order sent here by its transaction + hash, its receipt read again while it is pending.""" + if order_id.lower() in self._amm_orders: + order, plan, many = self._amm_orders[order_id.lower()] + if order.status == OrderStatus.PENDING: + receipt = await self._rpc(plan.network, "eth_getTransactionReceipt", [order.id]) + if receipt: + order = self._settle_amm(order, plan, many, receipt) + return order row = await self._call("GET", f"/orders/{order_id}") or {} if not row.get("orderHash"): raise OrderNotFound(f"{VENUE}: no order {order_id}") @@ -804,6 +1098,15 @@ async def fetch_orders( word = {"closed": "filled", "canceled": "cancelled"}.get(status or "", status) if word and word not in ("open", "filled", "cancelled", "expired"): raise BadRequest(f"{VENUE}: status is open, filled, cancelled or expired, not {status!r}") + ref = await self._ref(market_id) if market_id else None + if ref is not None and ref.amm: + # A pool has no orders at the venue: only the trades sent here. + wanted = ids.qualify(VENUE, ref.native) + mine = [o for o, _, _ in self._amm_orders.values() if o.market_id == wanted and ( + word is None or (word == "open" and o.status == OrderStatus.PENDING) + or (word == "filled" and o.status == OrderStatus.CLOSED) + or (word == "cancelled" and o.status == OrderStatus.CANCELED))] + return Page(sorted(mine, key=lambda o: o.created_at or 0, reverse=True), next_cursor=None) market = (await self._ob_ref(market_id)).market if market_id else None page = int(cursor) if cursor and cursor.isdigit() else 1 raw = await self._call("GET", "/orders", params={ @@ -831,24 +1134,41 @@ async def fetch_my_trades( self, *, market_id: str | None = None, order_id: str | None = None, since: int | None = None, limit: int | None = None, cursor: str | None = None, ) -> Page[Fill]: - """One page of this account's order-book trades, newest first, from - its history. The history names neither the order behind a trade nor - its fee, so `order_id` cannot filter it.""" + """One page of this account's trades, newest first, from its history: + order book and AMM alike, or one market's. The history names neither + the order behind a trade nor its fee, so `order_id` cannot filter it. + Without a market, page `n` is the `n`th page of each kind, merged.""" if order_id: raise BadRequest(f"{VENUE}: the account's history does not name orders; read the order's filled " f"amount with fetch_order") address = self._need_address() - market = (await self._ob_ref(market_id)).market if market_id else None + ref = await self._ref(market_id) if market_id else None page = int(cursor) if cursor and cursor.isdigit() else 1 - raw = await self._call("GET", f"/users/{address}/events", params={ - "trading_model": "ob", "network_id": ORDER_BOOK_NETWORK, "market_id": market, - "since": since // 1000 if since else None, "page": page, "limit": min(limit or 100, 100), - }) or {} - rows = raw.get("data") or [] if isinstance(raw, dict) else [] - fills = [f for f in (fill_of_action(row, account=self.account) for row in rows) if f is not None] + size = min(limit or 100, 100) + kinds = [ref.amm] if ref is not None else [False, True] + fills: list[Fill] = [] + more = False + for amm in kinds: + raw = await self._call("GET", f"/users/{address}/events", params={ + "trading_model": "amm" if amm else "ob", + "network_id": ref.network if ref is not None else None if amm else ORDER_BOOK_NETWORK, + "market_id": ref.market if ref is not None else None, + "since": since // 1000 if since else None, "page": page, "limit": size, + }) or {} + rows = raw.get("data") or [] if isinstance(raw, dict) else [] + for row in rows: + many = False + if amm and row.get("marketId") is not None: + many = await self._many(int(row.get("networkId") or ORDER_BOOK_NETWORK), int(row["marketId"])) + if ref is not None and ref.outcome is not None and int(row.get("outcomeId") or 0) != ref.outcome: + continue + fill = fill_of_action(row, account=self.account, amm=amm, many=many) + if fill is not None: + fills.append(fill) + more = more or (bool((raw.get("pagination") or {}).get("hasNext")) if isinstance(raw, dict) else False) if since: fills = [f for f in fills if f.timestamp >= since] - more = bool((raw.get("pagination") or {}).get("hasNext")) if isinstance(raw, dict) else False + fills.sort(key=lambda f: f.timestamp, reverse=True) return Page(fills, next_cursor=str(page + 1) if more else None) # -- account -------------------------------------------------------------- @@ -867,7 +1187,13 @@ async def fetch_positions(self, *, market_id: str | None = None, event_id: str | if not (isinstance(raw, dict) and (raw.get("pagination") or {}).get("hasNext")) or page >= 50: break page += 1 - positions = positions_of(rows, account=self.account) + many: set[tuple[int, int]] = set() + for row in rows: + if row.get("executionMode") == 0 and row.get("marketId") is not None: + key = (int(row.get("networkId") or ORDER_BOOK_NETWORK), int(row["marketId"])) + if key not in many and int(row.get("outcomeId") or 0) <= 1 and await self._many(*key): + many.add(key) + positions = positions_of(rows, account=self.account, many=many) if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) positions = [p for p in positions if p.market_id == wanted] @@ -910,6 +1236,288 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency="USD1", info={"schedule": schedule.model_dump()}, ) + # -- AMM markets ---------------------------------------------------------- + + async def _many(self, network: int, market: int) -> bool: + """Whether a pool has more than two outcomes (read once, kept).""" + key = (network, market) + if key not in self._outcome_counts: + try: + raw = await asyncio.to_thread(self.catalog.raw_market, native_id(network, market, amm=True)) + self._outcome_counts[key] = len(raw.get("outcomes") or []) + except (MarketNotFound, BadRequest): + return False + return self._outcome_counts[key] > 2 + + async def _rpc(self, network: int, method: str, params: list[Any]) -> Any: + url = self.rpc_urls.get(network) + if not url: + raise BadRequest(f"{VENUE}: no node for network {network}; pass rpc_urls={{{network}: ...}}") + answer = (await self.rpc.post(url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params})).json() + if not isinstance(answer, dict) or answer.get("error") or "result" not in answer: + error = answer.get("error") if isinstance(answer, dict) else answer + raise ExchangeError(f"{VENUE}: network {network} rpc {method}: {error}", body=answer) + return answer["result"] + + async def _eth_call(self, network: int, to: str, data: str) -> str: + return str(await self._rpc(network, "eth_call", [{"to": to, "data": data}, "latest"])) + + async def _amm_market(self, market_id: str) -> tuple[Ref, dict[str, Any]]: + """An AMM market's parts and the venue's record of it.""" + ref = await self._ref(market_id) + if not ref.amm: + raise InvalidOrder(f"{VENUE}: {ref.native} is an order-book market, not an AMM one") + if ref.network not in sig.AMM_CONTRACTS: + raise InvalidOrder(f"{VENUE}: AMM markets on network {ref.network} are not traded here") + raw = await asyncio.to_thread(self.catalog.raw_market, ref.market_native) + if not is_amm(raw): + raise InvalidOrder(f"{VENUE}: {ref.native} is not an AMM market") + self._outcome_counts[(ref.network, ref.market)] = len(raw.get("outcomes") or []) + return ref, raw + + async def plan_amm_order(self, request: OrderRequest) -> AmmPlan: + """Size an AMM order from the pool as it stands on chain, and build + its call, without sending anything. `plan.fills` is false (with a + `reason`) when the pool fills nothing within the worst price.""" + partial = amm_time_in_force(request) + ref, raw = await self._amm_market(request.market_id) + if str(raw.get("state") or "") != "open": + raise InvalidOrder(f"{VENUE}: {ref.native} is {raw.get('state')}; a pool trades only while open") + outcomes = raw.get("outcomes") or [] + index, action, worst = amm_leg(request, ref, len(outcomes)) + token = raw.get("token") or {} + decimals = int(token.get("decimals") or 18) + contract = sig.AMM_CONTRACTS[ref.network] + scale = 10 ** decimals + pools_units = sig.decode_uint_array(await self._eth_call(ref.network, contract, sig.amm_pools_calldata(ref.market))) + if len(pools_units) != len(outcomes) or any(n <= 0 for n in pools_units): + raise ExchangeError(f"{VENUE}: the pool of {ref.native} read back {pools_units}") + pools = [n / scale for n in pools_units] + buy_fee, sell_fee = amm_fees(raw) + held: Decimal | None = None + if action == "sell": + address = self._need_address() + shares = sig.decode_holdings(await self._eth_call( + ref.network, contract, sig.amm_holdings_calldata(ref.market, address))) + held = Decimal(shares[index] if index < len(shares) else 0) / scale + if held < D(request.quantity): + raise InsufficientFunds(f"{VENUE}: selling {request.quantity} of outcome {index} on {ref.native}, " + f"but the wallet holds {held}") + numbers = plan_amm_trade(pools, index=index, action=action, shares=D(request.quantity), worst=worst, + buy_fee=buy_fee, sell_fee=sell_fee, decimals=decimals, partial=partial, held=held) + plan = AmmPlan( + network=ref.network, market=ref.market, outcome=index, action=action, worst=worst, + requested=D(request.quantity), decimals=decimals, contract=contract, token=str(token.get("address") or ""), + symbol=str(token.get("symbol") or ""), reason=numbers.get("reason"), + info={"pools": pools, "buy_fee": buy_fee, "sell_fee": sell_fee, "partial": partial, + "outcomes": len(outcomes), "held": str(held) if held is not None else None}, + ) + if plan.reason is None: + plan.shares, plan.value, plan.average, plan.fee = (numbers["shares"], numbers["value"], numbers["average"], + numbers["fee"]) + plan.value_units, plan.bound_units = numbers["value_units"], numbers["bound_units"] + plan.calldata = (sig.amm_buy_calldata(ref.market, index, plan.bound_units, plan.value_units) if action == "buy" + else sig.amm_sell_calldata(ref.market, index, plan.value_units, plan.bound_units)) + return plan + + def _amm_order(self, request: OrderRequest, plan: AmmPlan, *, order_id: str, status: OrderStatus, + many: bool, info: dict[str, Any]) -> Order: + return Order( + id=order_id, + client_order_id=request.client_order_id, + venue=VENUE, + account=request.account or self.account, + market_id=market_id_of(plan.network, plan.market, amm=True, outcome=plan.outcome if many else None), + side=request.side, + type=request.type, + time_in_force=request.time_in_force, + status=status, + price=D(request.price), + quantity=D(request.quantity), + filled=Decimal("0"), + remaining=Decimal("0") if status != OrderStatus.PENDING else D(request.quantity), + reduce_only=request.reduce_only, + created_at=int(time.time() * 1000), + book=request.book, + trader=request.trader, + tags=request.tags, + info=info, + ) + + async def create_amm_order(self, request: OrderRequest) -> Order: + """Trade against an AMM market's pool: one transaction from the + wallet, sized from the pool on chain and bounded by the worst price + (see `plan_amm_order`), then its receipt read for the fill. A buy + checks the token's balance and its approval to the pool's contract + first (`approve_amm`); a sell checks the shares held. + + `request.params`: `wait` (default true) waits for the receipt, up to + `timeout_s` (default 120). An order not yet mined comes back + `pending`, its id the transaction hash; `fetch_order` reads it again. + When the pool fills nothing within the worst price, nothing is sent + and the order comes back `canceled`, its id `unsent-...`.""" + signer = self._need_signer("trading on an AMM market") + plan = await self.plan_amm_order(request) + many = plan.info["outcomes"] > 2 + if not plan.fills: + unsent = self._amm_order(request, plan, order_id=f"unsent-{secrets.token_hex(16)}", + status=OrderStatus.CANCELED, many=many, + info={"amm": True, "reason": plan.reason, "plan": self._plan_info(plan)}) + self._amm_orders[unsent.id] = (unsent, plan, many) + return unsent + if plan.action == "buy": + balance = int(await self._eth_call(plan.network, plan.token, sig.balance_calldata(signer.address)), 16) + if balance < plan.value_units: + raise InsufficientFunds(f"{VENUE}: the trade spends {plan.value} {plan.symbol}; the wallet holds " + f"{Decimal(balance) / (Decimal(10) ** plan.decimals)}") + allowance = int(await self._eth_call(plan.network, plan.token, sig.allowance_calldata( + signer.address, plan.contract)), 16) + if allowance < plan.value_units: + raise InsufficientFunds(f"{VENUE}: {plan.symbol} is not approved to the pool's contract for " + f"{plan.value}; call approve_amm({request.market_id!r}) once") + tx_hash = await self._send(plan.network, plan.contract, plan.calldata, what=f"the {plan.action}") + order = self._amm_order(request, plan, order_id=tx_hash, status=OrderStatus.PENDING, many=many, + info={"amm": True, "tx_hash": tx_hash, "plan": self._plan_info(plan)}) + self._amm_orders[tx_hash.lower()] = (order, plan, many) + if request.params.get("wait", True) is False: + return order + receipt = await self._receipt(plan.network, tx_hash, timeout_s=float(request.params.get("timeout_s", 120))) + return self._settle_amm(order, plan, many, receipt) if receipt else order + + @staticmethod + def _plan_info(plan: AmmPlan) -> dict[str, Any]: + return {"outcome": plan.outcome, "action": plan.action, "worst": str(plan.worst), "shares": str(plan.shares), + "value": str(plan.value), "average": str(plan.average) if plan.average is not None else None, + "fee": str(plan.fee), "bound": str(plan.bound), "contract": plan.contract, "network": plan.network} + + def _settle_amm(self, order: Order, plan: AmmPlan, many: bool, receipt: dict[str, Any]) -> Order: + """The order once its transaction is mined: filled from the receipt's + `MarketActionTx`, or rejected when it reverted.""" + if int(str(receipt.get("status") or "0x0"), 16) != 1: + settled = order.model_copy(update={"status": OrderStatus.REJECTED, "remaining": Decimal("0"), "info": { + **order.info, "reason": "reverted: the pool moved past the worst price, or the funds changed", + "receipt": receipt}}) + self._amm_orders[order.id.lower()] = (settled, plan, many) + return settled + wanted = sig.AMM_BUY if plan.action == "buy" else sig.AMM_SELL + actions = [a for a in sig.market_actions(receipt, contract=plan.contract, user=self.address) + if a["market_id"] == plan.market and a["outcome"] == plan.outcome and a["action"] == wanted] + fills = [amm_fill(a, tx_hash=order.id, plan=plan, many=many, order_id=order.id, + client_order_id=order.client_order_id, account=order.account) for a in actions] + filled = sum((f.quantity for f in fills), Decimal("0")) + value = sum((Decimal(f.info["value"]) for f in fills), Decimal("0")) + fee = sum((f.fee or Decimal("0") for f in fills), Decimal("0")) + average = None + if filled > 0: + average = value / filled if many or plan.outcome == 0 else Decimal("1") - value / filled + whole = filled >= order.quantity * (1 - Decimal(str(AMM_MARGIN))) + settled = order.model_copy(update={ + "status": OrderStatus.CLOSED if whole else OrderStatus.CANCELED, "filled": filled, + "remaining": Decimal("0"), "average_price": average, "cost": value if plan.action == "buy" else None, + "fee": fee, "fee_currency": plan.symbol or None, + "last_fill_price": fills[-1].price if fills else None, "last_fill_quantity": fills[-1].quantity if fills else None, + "updated_at": int(time.time() * 1000), + "info": {**order.info, "fills": fills, "block": receipt.get("blockNumber"), + "gas_used": receipt.get("gasUsed")}, + }) + self._amm_orders[order.id.lower()] = (settled, plan, many) + return settled + + async def _send(self, network: int, to: str, data: str, *, what: str) -> str: + """Sign and send one transaction from the wallet; its hash. A call the + node's simulation says would revert is refused before it is sent.""" + signer = self._need_signer("sending a transaction") + async with self._tx_lock: + nonce = int(await self._rpc(network, "eth_getTransactionCount", [signer.address, "pending"]), 16) + gas_price = int(await self._rpc(network, "eth_gasPrice", []), 16) + priority_fee = int(await self._rpc(network, "eth_maxPriorityFeePerGas", []), 16) + try: + estimate = int(await self._rpc(network, "eth_estimateGas", [{"from": signer.address, "to": to, + "data": data}]), 16) + except ExchangeError as exc: + raise OrderRejected(f"{VENUE}: {what} would revert: {exc}", reason="simulation", body=exc.body) from None + tx = { + "chainId": network, "nonce": nonce, "to": to, "value": 0, "data": data, "gas": int(estimate * 1.2), + "maxFeePerGas": gas_price * 2 + priority_fee, "maxPriorityFeePerGas": priority_fee, "type": 2, + } + return str(await self._rpc(network, "eth_sendRawTransaction", [signer.sign_transaction(tx)])) + + async def _receipt(self, network: int, tx_hash: str, *, timeout_s: float = 120.0, + poll_s: float = 1.0) -> dict[str, Any] | None: + """The transaction's receipt once mined, or `None` after `timeout_s`.""" + loop = asyncio.get_running_loop() + stop = loop.time() + timeout_s + while True: + receipt = await self._rpc(network, "eth_getTransactionReceipt", [tx_hash]) + if receipt: + return receipt + if loop.time() >= stop: + return None + await asyncio.sleep(poll_s) + + async def amm_allowance(self, market_id: str) -> Decimal: + """What the network's pool contract may spend of an AMM market's + token from this wallet, in tokens.""" + ref, raw = await self._amm_market(market_id) + token = raw.get("token") or {} + address = self._need_address() + result = await self._eth_call(ref.network, str(token.get("address")), sig.allowance_calldata( + address, sig.AMM_CONTRACTS[ref.network])) + return Decimal(int(result, 16)) / (Decimal(10) ** int(token.get("decimals") or 18)) + + async def approve_amm(self, market_id: str, amount: Decimal | None = None, *, wait: bool = True, + timeout_s: float = 120.0) -> dict[str, Any]: + """Approve an AMM market's token to its network's pool contract, once + per token and network: unlimited by default, or `amount` tokens. One + transaction from the wallet, which pays its gas.""" + ref, raw = await self._amm_market(market_id) + token = raw.get("token") or {} + value = MAX_UINT256 if amount is None else _units(D(amount), int(token.get("decimals") or 18), ROUND_CEILING) + contract = sig.AMM_CONTRACTS[ref.network] + tx_hash = await self._send(ref.network, str(token.get("address")), sig.approve_calldata(contract, value), + what="the approval") + if not wait: + return {"transaction_hash": tx_hash} + receipt = await self._receipt(ref.network, tx_hash, timeout_s=timeout_s) + if receipt is None: + raise ExchangeError(f"{VENUE}: approval {tx_hash} unmined after {timeout_s}s; it may yet confirm") + if int(str(receipt.get("status") or "0x0"), 16) != 1: + raise OrderRejected(f"{VENUE}: approval {tx_hash} reverted", reason="reverted", info=receipt) + return {"transaction_hash": tx_hash, "receipt": receipt} + + async def redeem_amm(self, market_id: str, *, wait: bool = True, timeout_s: float = 120.0) -> dict[str, Any]: + """Claim a resolved AMM market's payout: the winning outcome's shares + (`claimWinnings`), or, on a voided market, each outcome held + (`claimVoidedOutcomeShares`). One transaction each from the wallet.""" + ref, raw = await self._amm_market(market_id) + if str(raw.get("state") or "") != "resolved": + raise InvalidOrder(f"{VENUE}: {ref.native} is {raw.get('state')}; only a resolved market pays out") + contract = sig.AMM_CONTRACTS[ref.network] + held = sig.decode_holdings(await self._eth_call(ref.network, contract, sig.amm_holdings_calldata( + ref.market, self._need_address()))) + if raw.get("voided"): + calls = [sig.amm_claim_voided_calldata(ref.market, k) for k, n in enumerate(held) if n > 0] + else: + winner = int(raw.get("resolvedOutcomeId") if raw.get("resolvedOutcomeId") is not None else -1) + calls = [sig.amm_claim_calldata(ref.market)] if 0 <= winner < len(held) and held[winner] > 0 else [] + if not calls: + raise InvalidOrder(f"{VENUE}: the wallet holds nothing on {ref.native} to claim") + hashes = [await self._send(ref.network, contract, data, what="the claim") for data in calls] + if not wait: + return {"transaction_hashes": hashes} + receipts = [] + for tx_hash in hashes: + receipt = await self._receipt(ref.network, tx_hash, timeout_s=timeout_s) + if receipt is None: + raise ExchangeError(f"{VENUE}: claim {tx_hash} unmined after {timeout_s}s; it may yet confirm") + if int(str(receipt.get("status") or "0x0"), 16) != 1: + raise OrderRejected(f"{VENUE}: claim {tx_hash} reverted", reason="reverted", info=receipt) + receipts.append(receipt) + scale = Decimal(10) ** int((raw.get("token") or {}).get("decimals") or 18) + paid = sum((Decimal(int(a["value"])) / scale for r in receipts + for a in sig.market_actions(r, contract=contract, user=self.address)), Decimal("0")) + return {"transaction_hashes": hashes, "receipts": receipts, "paid": paid} + # -- on-chain helpers ----------------------------------------------------- def approval_calls(self) -> list[dict[str, str]]: diff --git a/synpath/trading/myriad_signing.py b/synpath/trading/myriad_signing.py index 153b2d2..7b522e8 100644 --- a/synpath/trading/myriad_signing.py +++ b/synpath/trading/myriad_signing.py @@ -16,6 +16,15 @@ as `x-api-key`, `x-api-timestamp` and `x-api-signature`. The venue refuses a bare key. Without a key the wallet signs in (SIWE): the venue's login message, signed as a personal message, buys a short-lived session token. + +**AMM markets** trade on the network's `PredictionMarket` contract, one call +per trade: `buy(marketId, outcomeId, minOutcomeSharesToBuy, value)` spends +`value` of the market's token (fees come out of it) for at least the minimum +shares, and `sell(marketId, outcomeId, value, maxOutcomeSharesToSell)` pays +out exactly `value` for at most the maximum shares. Amounts are in the +token's smallest unit, and shares count in the same unit. The selectors are +those in the venue's quote calldata and in the deployed contracts' code; each +trade logs a `MarketActionTx`. """ from __future__ import annotations @@ -29,7 +38,7 @@ import httpx from .errors import InvalidOrder -from .polymarket_signing import WalletSigner, _eth, approve_calldata, set_approval_for_all_calldata +from .polymarket_signing import WalletSigner, _call, _eth, approve_calldata, set_approval_for_all_calldata from .predict_fun_signing import _domain_separator, _personal_digest DOMAIN_NAME = "MyriadCTFExchange" @@ -297,3 +306,107 @@ def approval_calls(collateral: str = USD1) -> list[dict[str, str]]: {"to": collateral, "data": approve_calldata(EXCHANGE)}, {"to": CONDITIONAL_TOKENS, "data": set_approval_for_all_calldata(EXCHANGE)}, ] + + +# --------------------------------------------------------------------------- +# AMM markets +# --------------------------------------------------------------------------- + +AMM_CONTRACTS: dict[int, str] = { + 56: "0x39E66eE6b2ddaf4DEfDEd3038E0162180dbeF340", + 2741: "0x3e0F5F8F5Fb043aBFA475C0308417Bf72c463289", + 59144: "0x39E66eE6b2ddaf4DEfDEd3038E0162180dbeF340", +} +"""The `PredictionMarket` contract per network (BNB Chain, Abstract, Linea), +from the venue's contract list.""" + +AMM_RPC_URLS: dict[int, str] = { + 56: "https://bsc-dataseed.bnbchain.org", + 2741: "https://api.mainnet.abs.xyz", + 59144: "https://rpc.linea.build", +} +"""Public nodes, used when no other is given.""" + +MARKET_ACTION_TX = "0x9dcabe311735ed0d65f0c22c5425d1f17331f94c9d0767f59e58473cf95ada61" +"""`MarketActionTx(address indexed user, uint8 indexed action, uint256 indexed +marketId, uint256 outcomeId, uint256 shares, uint256 value, uint256 timestamp)`.""" + +AMM_BUY, AMM_SELL = 0, 1 +"""The `action` of a `MarketActionTx` for a buy and a sell.""" + + +def amm_buy_calldata(market_id: int, outcome: int, min_shares: int, value: int) -> str: + return _call("buy(uint256,uint256,uint256,uint256)", ["uint256"] * 4, + [int(market_id), int(outcome), int(min_shares), int(value)]) + + +def amm_sell_calldata(market_id: int, outcome: int, value: int, max_shares: int) -> str: + return _call("sell(uint256,uint256,uint256,uint256)", ["uint256"] * 4, + [int(market_id), int(outcome), int(value), int(max_shares)]) + + +def amm_claim_calldata(market_id: int) -> str: + """`claimWinnings(marketId)`: a resolved market's winning shares paid out.""" + return _call("claimWinnings(uint256)", ["uint256"], [int(market_id)]) + + +def amm_claim_voided_calldata(market_id: int, outcome: int) -> str: + """`claimVoidedOutcomeShares(marketId, outcomeId)`: one outcome's shares of a voided market.""" + return _call("claimVoidedOutcomeShares(uint256,uint256)", ["uint256", "uint256"], [int(market_id), int(outcome)]) + + +def amm_pools_calldata(market_id: int) -> str: + """`getMarketOutcomesShares(marketId)`: the pool's shares per outcome.""" + return _call("getMarketOutcomesShares(uint256)", ["uint256"], [int(market_id)]) + + +def amm_holdings_calldata(market_id: int, user: str) -> str: + """`getUserMarketShares(marketId, user)`: `(liquidity, shares per outcome)`.""" + _, _, _, _, to_checksum_address = _eth() + return _call("getUserMarketShares(uint256,address)", ["uint256", "address"], [int(market_id), to_checksum_address(user)]) + + +def balance_calldata(owner: str) -> str: + _, _, _, _, to_checksum_address = _eth() + return _call("balanceOf(address)", ["address"], [to_checksum_address(owner)]) + + +def allowance_calldata(owner: str, spender: str) -> str: + _, _, _, _, to_checksum_address = _eth() + return _call("allowance(address,address)", ["address", "address"], + [to_checksum_address(owner), to_checksum_address(spender)]) + + +def _words(result: str) -> list[int]: + raw = str(result or "0x").removeprefix("0x") + return [int(raw[i:i + 64], 16) for i in range(0, len(raw) - len(raw) % 64, 64)] + + +def decode_uint_array(result: str, *, offset_word: int = 0) -> list[int]: + """A `uint256[]` return value, its offset in word `offset_word`.""" + words = _words(result) + start = words[offset_word] // 32 + return words[start + 1:start + 1 + words[start]] + + +def decode_holdings(result: str) -> list[int]: + """`getUserMarketShares`'s shares per outcome (after the liquidity word).""" + return decode_uint_array(result, offset_word=1) + + +def market_actions(receipt: dict[str, Any], *, contract: str, user: str | None = None) -> list[dict[str, int | str]]: + """The `MarketActionTx` logs in a receipt from `contract`, the user's + when given: `{user, action, market_id, outcome, shares, value, timestamp}`, + amounts in the token's smallest unit.""" + out: list[dict[str, int | str]] = [] + for log in receipt.get("logs") or []: + topics = [str(t).lower() for t in log.get("topics") or []] + if str(log.get("address") or "").lower() != contract.lower() or len(topics) < 4 or topics[0] != MARKET_ACTION_TX: + continue + who = "0x" + topics[1][-40:] + if user is not None and who.lower() != user.lower(): + continue + outcome, shares, value, stamp = (_words(log.get("data") or "0x") + [0, 0, 0, 0])[:4] + out.append({"user": who, "action": int(topics[2], 16), "market_id": int(topics[3], 16), "outcome": outcome, + "shares": shares, "value": value, "timestamp": stamp}) + return out diff --git a/tests/samples/myriad_quote_abstract_buy.json b/tests/samples/myriad_quote_abstract_buy.json new file mode 100644 index 0000000..5c14e66 --- /dev/null +++ b/tests/samples/myriad_quote_abstract_buy.json @@ -0,0 +1 @@ +{"value":10,"shares":26.215168002574792,"shares_threshold":25.690864642523294,"price_average":0.38145855098154713,"price_before":0.36838541,"price_after":0.3716471430516322,"calldata":"0x1281311d0000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000018802f10000000000000000000000000000000000000000000000000000000000989680","net_amount":9.7,"fees":{"treasury":0.1,"distributor":0.1,"fee":0.1}} \ No newline at end of file diff --git a/tests/samples/myriad_quote_abstract_sell.json b/tests/samples/myriad_quote_abstract_sell.json new file mode 100644 index 0000000..d09a67a --- /dev/null +++ b/tests/samples/myriad_quote_abstract_sell.json @@ -0,0 +1 @@ +{"value":1.8403765902850164,"shares":5,"shares_threshold":1.803569058479316,"price_average":0.3680753180570033,"price_before":0.36838541,"price_after":0.3677653337699941,"calldata":"0x3620875e0000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000001c14f900000000000000000000000000000000000000000000000000000000004dd1e0","net_amount":1.8403765902850164,"fees":{"treasury":0,"distributor":0,"fee":0}} \ No newline at end of file diff --git a/tests/test_myriad_amm_trading.py b/tests/test_myriad_amm_trading.py new file mode 100644 index 0000000..9235448 --- /dev/null +++ b/tests/test_myriad_amm_trading.py @@ -0,0 +1,531 @@ +"""Myriad AMM orders: the call encoding against the venue's own quote +calldata, the pool maths against its quotes, the slippage bound, and the +adapter against a scripted chain (nothing is sent anywhere).""" +from __future__ import annotations + +import asyncio +import copy +import json +import math +from decimal import Decimal + +import httpx +import pytest +from eth_abi import decode as abi_decode +from eth_abi import encode as abi_encode +from eth_account import Account as EthAccount +from eth_account.typed_transactions import TypedTransaction +from hexbytes import HexBytes + +from synpath.myriad import parse_id +from synpath.trading import myriad_signing as sig +from synpath.trading.credentials import MyriadCredentials +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderRejected +from synpath.trading.myriad import ( + MyriadTrading, amm_buy_cost, amm_buy_shares, amm_leg, amm_sell_shares, amm_sell_value, amm_time_in_force, + fill_of_action, plan_amm_trade, positions_of, +) +from synpath.trading.types import OrderRequest, OrderStatus, OrderType, Side, TimeInForce + +from conftest import load + +KEY = "0x" + "11" * 32 +OWNER = EthAccount.from_key(KEY) +RAW = load("myriad_market_amm.json") +"""Market 120473 on BNB Chain: two outcomes, no fees, USD1.""" +POOLS = [o["shares"] for o in RAW["outcomes"]] +MARKET_ID = "myriad:56-amm-120473" +CONTRACT = sig.AMM_CONTRACTS[56] + +ABSTRACT_POOLS = [1381.597397, 2368.81549] +"""Market 768 on Abstract (USDC.e, 6 decimals; 3% on buys), read with the two quotes below.""" + + +def request(**changes) -> OrderRequest: + base = {"market_id": MARKET_ID, "side": Side.BUY, "quantity": Decimal("10"), "price": Decimal("0.7"), + "type": OrderType.MARKET} + return OrderRequest(**{**base, **changes}) + + +def args_of(calldata: str) -> list[int]: + return list(abi_decode(["uint256"] * 4, bytes.fromhex(calldata[10:]))) + + +# --------------------------------------------------------------------------- +# Encoding and maths, against the venue's quotes +# --------------------------------------------------------------------------- + +class TestCalldata: + @pytest.mark.parametrize("name", ["myriad_quote_buy.json", "myriad_quote_abstract_buy.json"]) + def test_a_buy_is_the_call_the_venue_quotes(self, name): + quote = load(name) + market, outcome, min_shares, value = args_of(quote["calldata"]) + assert sig.amm_buy_calldata(market, outcome, min_shares, value) == quote["calldata"] + assert quote["calldata"].startswith("0x1281311d"), "buy(uint256,uint256,uint256,uint256)" + + @pytest.mark.parametrize("name", ["myriad_quote_sell.json", "myriad_quote_abstract_sell.json"]) + def test_a_sell_is_the_call_the_venue_quotes(self, name): + quote = load(name) + market, outcome, value, max_shares = args_of(quote["calldata"]) + assert sig.amm_sell_calldata(market, outcome, value, max_shares) == quote["calldata"] + assert quote["calldata"].startswith("0x3620875e"), "sell(uint256,uint256,uint256,uint256)" + + def test_the_quote_bounds_the_shares_not_the_value(self): + """The quote's slippage is already in its calldata: minimum shares on a + buy, maximum shares on a sell, the value exact. The token's decimals + count the shares too (6 on Abstract's USDC.e).""" + buy, sell = load("myriad_quote_abstract_buy.json"), load("myriad_quote_abstract_sell.json") + _, outcome, min_shares, value = args_of(buy["calldata"]) + assert (outcome, value) == (1, 10 * 10**6) and min_shares == round(buy["shares_threshold"] * 10**6) + _, _, value, max_shares = args_of(sell["calldata"]) + assert value == round(sell["value"] * 10**6) and max_shares == round(5 * 1.02 * 10**6) + + def test_claims_and_reads(self): + assert sig.amm_claim_calldata(120473) == "0x677bd9ff" + abi_encode(["uint256"], [120473]).hex() + assert sig.amm_claim_voided_calldata(7, 1).startswith("0xec93f0f5") + assert sig.amm_pools_calldata(1).startswith("0xbb986173") + assert sig.amm_holdings_calldata(1, OWNER.address).startswith("0xb31eb895") + array = "0x" + abi_encode(["uint256[]"], [[5, 7]]).hex() + assert sig.decode_uint_array(array) == [5, 7] + assert sig.decode_holdings("0x" + abi_encode(["uint256", "uint256[]"], [9, [3, 0]]).hex()) == [3, 0] + + +class TestPoolMaths: + def test_buys_match_the_venues_quotes(self): + assert amm_buy_shares(POOLS, 0, 25, 0.0) == pytest.approx(load("myriad_quote_buy.json")["shares"], rel=1e-9) + # 3% on buys (fee, treasury, distributor), out of the value spent. + assert amm_buy_shares(ABSTRACT_POOLS, 1, 10, 0.03) == pytest.approx( + load("myriad_quote_abstract_buy.json")["shares"], rel=1e-12) + + def test_sells_match_the_venues_quotes(self): + assert amm_sell_value(POOLS, 0, 20, 0.0) == pytest.approx(load("myriad_quote_sell.json")["value"], rel=1e-9) + assert amm_sell_value(ABSTRACT_POOLS, 1, 5, 0.0) == pytest.approx( + load("myriad_quote_abstract_sell.json")["value"], rel=1e-9) + + def test_inverses(self): + for fee in (0.0, 0.02): + assert amm_buy_shares(POOLS, 1, amm_buy_cost(POOLS, 1, 40, fee), fee) == pytest.approx(40, rel=1e-12) + assert amm_sell_shares(POOLS, 0, amm_sell_value(POOLS, 0, 40, fee), fee) == pytest.approx(40, rel=1e-12) + + def test_a_sell_fee_is_on_top_of_the_payout(self): + """With a fee the pool pays out `value / (1 - fee)`, so the same shares fetch less.""" + assert amm_sell_value(POOLS, 0, 20, 0.01) < amm_sell_value(POOLS, 0, 20, 0.0) + assert amm_sell_shares(POOLS, 0, 9.9, 0.01) == pytest.approx(amm_sell_shares(POOLS, 0, 10, 0.0), rel=1e-12) + + +class TestSlippageBound: + def test_a_buy_spends_for_at_least_value_over_the_worst_price(self): + plan = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("10"), worst=Decimal("0.7"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert plan["bound_units"] == math.ceil(Decimal(plan["value_units"]) / Decimal("0.7")) + assert plan["shares"] == pytest.approx(Decimal("10"), rel=Decimal("1e-9")) + assert plan["average"] < Decimal("0.7") and plan["bound_units"] <= plan["shares"] * 10**18 + + def test_a_sell_is_paid_for_at_most_value_over_the_worst_price(self): + plan = plan_amm_trade(POOLS, index=0, action="sell", shares=Decimal("10"), worst=Decimal("0.6"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert plan["bound_units"] == int(Decimal(plan["value_units"]) / Decimal("0.6")) + assert plan["bound_units"] >= plan["shares"] * 10**18 and plan["average"] > Decimal("0.6") + held = plan_amm_trade(POOLS, index=0, action="sell", shares=Decimal("10"), worst=Decimal("0.6"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True, held=Decimal("10.5")) + assert held["bound_units"] == 105 * 10**17, "never more than the wallet holds" + + def test_known_answer(self): + """Fixed pool, fixed order: the call may never drift.""" + plan = plan_amm_trade(ABSTRACT_POOLS, index=1, action="buy", shares=Decimal("20"), worst=Decimal("0.45"), + buy_fee=0.03, sell_fee=0.0, decimals=6, partial=False) + assert (plan["value_units"], plan["bound_units"]) == (7621195, 16935989) + assert sig.amm_buy_calldata(768, 1, plan["bound_units"], plan["value_units"]) == ( + "0x1281311d" + abi_encode(["uint256"] * 4, [768, 1, 16935989, 7621195]).hex()) + assert plan["fee"] == Decimal("0.22863585") + + def test_ioc_trades_what_fits_within_the_worst_price(self): + whole = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("500"), worst=Decimal("0.7"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=False) + assert "reason" in whole and "worse than 0.7" in whole["reason"] + part = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("500"), worst=Decimal("0.7"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert Decimal("0") < part["shares"] < Decimal("500") + assert part["average"] == pytest.approx(Decimal("0.7"), rel=Decimal("1e-5")) and part["average"] <= Decimal("0.7") + assert part["bound_units"] <= part["shares"] * 10**18, "the bound holds at the pool as read" + + def test_nothing_fits(self): + plan = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("5"), worst=Decimal("0.5"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert plan == {"reason": "the pool fills nothing at 0.5 or better"} + + +class TestOrderShape: + @pytest.mark.parametrize("changes, partial", [ + ({}, True), + ({"type": OrderType.LIMIT, "time_in_force": TimeInForce.IOC}, True), + ({"type": OrderType.LIMIT, "time_in_force": TimeInForce.FOK}, False), + ({"time_in_force": TimeInForce.FOK}, False), + ]) + def test_market_ioc_and_fok(self, changes, partial): + assert amm_time_in_force(request(**changes)) is partial + + @pytest.mark.parametrize("changes, match", [ + ({"type": OrderType.LIMIT}, "taker-only"), + ({"type": OrderType.LIMIT, "time_in_force": TimeInForce.GTD, "expires_at": 1}, "taker-only"), + ({"type": OrderType.LIMIT, "post_only": True}, "post-only"), + ({"price": None}, "worst"), + ({"price": Decimal("1")}, "outside"), + ]) + def test_resting_orders_are_refused(self, changes, match): + with pytest.raises(InvalidOrder, match=match): + amm_time_in_force(request(**changes)) + + def test_two_outcomes_are_yes_and_no(self): + ref = parse_id("56-amm-120473") + assert amm_leg(request(), ref, 2) == (0, "buy", Decimal("0.7")) + assert amm_leg(request(side=Side.SELL), ref, 2) == (1, "buy", Decimal("0.3")) + assert amm_leg(request(side=Side.SELL, reduce_only=True), ref, 2) == (0, "sell", Decimal("0.7")) + assert amm_leg(request(side=Side.BUY, reduce_only=True), ref, 2) == (1, "sell", Decimal("0.3")) + with pytest.raises(InvalidOrder, match="3 outcomes"): + amm_leg(request(), ref, 3) + + def test_one_outcome_of_many(self): + ref = parse_id("2741-amm-412-2") + assert amm_leg(request(), ref, 4) == (2, "buy", Decimal("0.7")) + assert amm_leg(request(side=Side.SELL, reduce_only=True), ref, 4) == (2, "sell", Decimal("0.7")) + with pytest.raises(InvalidOrder, match="rest of the pool"): + amm_leg(request(side=Side.SELL), ref, 4) + with pytest.raises(InvalidOrder, match="names outcome 2"): + amm_leg(request(), ref, 2) + + +# --------------------------------------------------------------------------- +# The adapter against a scripted chain +# --------------------------------------------------------------------------- + +def word(n: int) -> str: + return "0x" + n.to_bytes(32, "big").hex() + + +class Chain: + """Answers the node's calls for one pool and the wallet, decodes and + keeps every signed transaction, and mines it with a scripted receipt.""" + + def __init__(self, *, pools=POOLS, decimals=18, balance=10**24, allowance=2**256 - 1, held=(0, 0), + estimate_error=None, receipt=None): + self.scale = 10**decimals + self.pools = [int(Decimal(str(p)) * self.scale) for p in pools] + self.balance, self.allowance, self.held = balance, allowance, list(held) + self.estimate_error = estimate_error + self.receipt = receipt + self.sent: list[dict] = [] + self.calls: list[dict] = [] + self.mined = True + + def __call__(self, req: httpx.Request) -> httpx.Response: + body = json.loads(req.content) + method, params = body["method"], body["params"] + result = self.answer(method, params) + if isinstance(result, dict) and "error" in result: + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, **result}) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": result}) + + def answer(self, method, params): + if method == "eth_call": + call = params[0] + self.calls.append(call) + selector = call["data"][:10] + if selector == "0xbb986173": + return "0x" + abi_encode(["uint256[]"], [self.pools]).hex() + if selector == "0xb31eb895": + return "0x" + abi_encode(["uint256", "uint256[]"], [0, self.held]).hex() + if selector == "0x70a08231": + return word(self.balance) + if selector == "0xdd62ed3e": + return word(self.allowance) + raise AssertionError(f"unexpected call {selector}") + if method == "eth_getTransactionCount": + return hex(5) + if method in ("eth_gasPrice", "eth_maxPriorityFeePerGas"): + return hex(10**8) + if method == "eth_estimateGas": + if self.estimate_error: + return {"error": {"code": 3, "message": self.estimate_error}} + return hex(200_000) + if method == "eth_sendRawTransaction": + raw = HexBytes(params[0]) + tx = TypedTransaction.from_bytes(raw).as_dict() + tx["from"] = EthAccount.recover_transaction(raw) + self.sent.append(tx) + return "0x" + "ab" * 32 + if method == "eth_getTransactionReceipt": + if not self.mined: + return None + return self.receipt(self.sent[-1]) if self.receipt else {"status": "0x1", "logs": []} + raise AssertionError(f"unexpected rpc {method}") + + +def action_log(*, action: int, market: int, outcome: int, shares: int, value: int, user=OWNER.address, + contract=CONTRACT, timestamp=1792900000) -> dict: + return { + "address": contract.lower(), + "topics": [sig.MARKET_ACTION_TX, "0x" + "00" * 12 + user[2:].lower(), word(action), word(market)], + "data": "0x" + abi_encode(["uint256"] * 4, [outcome, shares, value, timestamp]).hex(), + } + + +def filled_as_planned(shares_factor=Decimal("1")): + """A receipt that fills what the call asked: the value exact, the shares + the pool gives (a buy) or the shares a sell burns.""" + def receipt(tx): + data = "0x" + bytes(tx["data"]).hex() + market, outcome, a, b = args_of(data) + buying = data.startswith("0x1281311d") + value = b if buying else a + pools = [p / 10**18 for p in Chain().pools] + shares = (amm_buy_shares(pools, outcome, value / 10**18, 0.0) if buying + else amm_sell_shares(pools, outcome, value / 10**18, 0.0)) + units = int(Decimal(str(shares)) * shares_factor * 10**18) + return {"status": "0x1", "blockNumber": "0x10", "gasUsed": "0x30d40", "logs": [ + action_log(action=0 if buying else 1, market=market, outcome=outcome, shares=units, value=value)]} + return receipt + + +class Catalog: + def __init__(self, raw=RAW): + self.raw = raw + + def _resolve(self, market_id): + return parse_id(market_id), None + + def raw_market(self, market_id): + return self.raw + + def fetch_fee_schedule(self, market_id): # pragma: no cover - unused here + raise AssertionError + + def close(self): + pass + + +def adapter(chain: Chain, *, raw=RAW, venue=None) -> MyriadTrading: + creds = MyriadCredentials(private_key=KEY, api_key="key-1", api_secret="secret-1") + return MyriadTrading( + creds, catalog=Catalog(raw), + client=httpx.AsyncClient(transport=httpx.MockTransport(venue or (lambda r: httpx.Response(404)))), + rpc_client=httpx.AsyncClient(transport=httpx.MockTransport(chain)), + ) + + +def run(coro): + return asyncio.run(coro) + + +async def trade(chain: Chain, req: OrderRequest, *, raw=RAW): + async with adapter(chain, raw=raw) as myriad: + return await myriad.create_order(req) + + +class TestAdapter: + def test_a_buy_is_one_signed_call_and_its_fill_comes_from_the_receipt(self): + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(client_order_id="c-1"))) + [tx] = chain.sent + assert tx["from"] == OWNER.address and tx["to"] == HexBytes(CONTRACT) and tx["chainId"] == 56 + assert tx["nonce"] == 5 and tx["gas"] == 240_000 and tx["value"] == 0 + market, outcome, min_shares, value = args_of("0x" + bytes(tx["data"]).hex()) + assert (market, outcome) == (120473, 0) and min_shares == math.ceil(Decimal(value) / Decimal("0.7")) + assert {c["data"][:10] for c in chain.calls} == {"0xbb986173", "0x70a08231", "0xdd62ed3e"} + assert order.id == "0x" + "ab" * 32 and order.status == OrderStatus.CLOSED + assert order.market_id == MARKET_ID and order.client_order_id == "c-1" + assert order.filled == pytest.approx(Decimal("10"), rel=Decimal("1e-9")) + assert order.average_price == pytest.approx(Decimal(value) / 10**18 / order.filled) + assert order.average_price < Decimal("0.7") and order.fee == 0 and order.fee_currency == "USD1" + [fill] = order.info["fills"] + assert fill.id == f"{order.id}:0:buy" and fill.order_id == order.id and fill.side == Side.BUY + + def test_buying_no_buys_outcome_one(self): + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(side=Side.SELL, price=Decimal("0.6")))) + market, outcome, min_shares, value = args_of("0x" + bytes(chain.sent[0]["data"]).hex()) + assert outcome == 1 and min_shares == math.ceil(Decimal(value) / Decimal("0.4")) + assert order.side == Side.SELL and order.average_price > Decimal("0.6"), "the YES price of a NO buy" + assert order.info["fills"][0].side == Side.SELL + + def test_a_sell_checks_the_shares_held_and_bounds_what_it_burns(self): + chain = Chain(held=[15 * 10**18, 0], receipt=filled_as_planned()) + order = run(trade(chain, request(side=Side.SELL, reduce_only=True, price=Decimal("0.6")))) + market, outcome, value, max_shares = args_of("0x" + bytes(chain.sent[0]["data"]).hex()) + assert chain.sent[0]["data"][:4] == bytes.fromhex("3620875e") and outcome == 0 + assert max_shares == min(int(Decimal(value) / Decimal("0.6")), 15 * 10**18) + assert order.status == OrderStatus.CLOSED and order.average_price > Decimal("0.6") + assert order.info["fills"][0].id.endswith(":0:sell") + with pytest.raises(InsufficientFunds, match="holds 5"): + run(trade(Chain(held=[5 * 10**18, 0]), request(side=Side.SELL, reduce_only=True, price=Decimal("0.6")))) + + def test_fees_are_reported(self): + raw = copy.deepcopy(RAW) + raw["fees"]["buy"] = {"fee": 0.01, "treasury_fee": 0, "distributor_fee": 0.01} + raw["fees"]["sell"] = {"fee": 0.01, "treasury_fee": 0, "distributor_fee": 0} + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(), raw=raw)) + value = Decimal(args_of("0x" + bytes(chain.sent[0]["data"]).hex())[3]) / 10**18 + assert order.fee == value * Decimal("0.02") and order.info["plan"]["fee"] == str(value * Decimal("0.02")) + chain = Chain(held=[15 * 10**18, 0], receipt=filled_as_planned()) + order = run(trade(chain, request(side=Side.SELL, reduce_only=True, price=Decimal("0.6")), raw=raw)) + paid = Decimal(args_of("0x" + bytes(chain.sent[0]["data"]).hex())[2]) / 10**18 + assert order.fee == pytest.approx(paid / Decimal("0.99") - paid) + + def test_without_approval_nothing_is_sent(self): + chain = Chain(allowance=0) + with pytest.raises(InsufficientFunds, match="approve_amm"): + run(trade(chain, request())) + with pytest.raises(InsufficientFunds, match="holds"): + run(trade(Chain(balance=10**18), request())) + assert chain.sent == [] + + def test_a_simulated_revert_is_refused_before_sending(self): + chain = Chain(estimate_error="execution reverted: minimum shares not met") + with pytest.raises(OrderRejected, match="would revert") as caught: + run(trade(chain, request())) + assert caught.value.reason == "simulation" and chain.sent == [] + + def test_a_reverted_trade_is_rejected(self): + chain = Chain(receipt=lambda tx: {"status": "0x0", "logs": []}) + order = run(trade(chain, request())) + assert order.status == OrderStatus.REJECTED and order.filled == 0 and "reverted" in order.info["reason"] + + def test_nothing_within_the_worst_price_sends_nothing(self): + chain = Chain() + order = run(trade(chain, request(price=Decimal("0.5")))) + assert order.status == OrderStatus.CANCELED and order.filled == 0 and chain.sent == [] + assert "nothing" in order.info["reason"] + assert run(trade(Chain(), request(quantity=Decimal("500"), time_in_force=TimeInForce.FOK))).status == \ + OrderStatus.CANCELED + + def test_ioc_fills_part(self): + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(quantity=Decimal("500")))) + assert order.status == OrderStatus.CANCELED and Decimal("0") < order.filled < Decimal("500") + assert order.average_price <= Decimal("0.7") + + def test_a_pending_trade_is_read_again(self): + chain = Chain(receipt=filled_as_planned()) + chain.mined = False + + async def go(): + async with adapter(chain) as myriad: + order = await myriad.create_order(request(params={"wait": False})) + opened = await myriad.fetch_open_orders(market_id=MARKET_ID) + with pytest.raises(InvalidOrder, match="nothing to cancel"): + await myriad.cancel_order(order.id) + chain.mined = True + return order, opened, await myriad.fetch_order(order.id), await myriad.fetch_orders(market_id=MARKET_ID) + + pending, opened, done, page = run(go()) + assert pending.status == OrderStatus.PENDING and [o.id for o in opened] == [pending.id] + assert done.status == OrderStatus.CLOSED and done.filled > 0 and [o.status for o in page] == [OrderStatus.CLOSED] + + def test_a_closed_market_is_refused(self): + raw = {**RAW, "state": "closed"} + with pytest.raises(InvalidOrder, match="closed"): + run(trade(Chain(), request(), raw=raw)) + + def test_a_resting_order_is_refused_before_any_read(self): + chain = Chain() + with pytest.raises(InvalidOrder, match="taker-only"): + run(trade(chain, request(type=OrderType.LIMIT))) + assert chain.calls == [] + + def test_approve_and_redeem(self): + resolved = {**RAW, "state": "resolved", "resolvedOutcomeId": 1, "voided": False} + chain = Chain(held=[0, 4 * 10**18], receipt=lambda tx: {"status": "0x1", "logs": [ + action_log(action=4, market=120473, outcome=1, shares=4 * 10**18, value=4 * 10**18)]}) + + async def go(): + async with adapter(chain, raw=resolved) as myriad: + approved = await myriad.approve_amm(MARKET_ID) + redeemed = await myriad.redeem_amm(MARKET_ID) + return approved, redeemed + + approved, redeemed = run(go()) + approve, claim = chain.sent + assert approve["to"] == HexBytes(RAW["token"]["address"]) + assert "0x" + bytes(approve["data"]).hex() == sig.approve_calldata(CONTRACT) + assert claim["to"] == HexBytes(CONTRACT) and "0x" + bytes(claim["data"]).hex() == sig.amm_claim_calldata(120473) + assert redeemed["paid"] == Decimal("4") and approved["receipt"]["status"] == "0x1" + voided = {**resolved, "voided": True} + chain = Chain(held=[2, 3]) + + async def void(): + async with adapter(chain, raw=voided) as myriad: + return await myriad.redeem_amm(MARKET_ID, wait=False) + + assert len(run(void())["transaction_hashes"]) == 2 + assert ["0x" + bytes(tx["data"]).hex() for tx in chain.sent] == [ + sig.amm_claim_voided_calldata(120473, 0), sig.amm_claim_voided_calldata(120473, 1)] + + +class TestConsistency: + """The fill an AMM order reports, the account's history of the same trade + and the position it makes all name the same market, and the fill and the + history row the same id.""" + + def test_two_outcomes(self): + order = run(trade(Chain(receipt=filled_as_planned()), request())) + [fill] = order.info["fills"] + row = {"action": "buy", "marketId": 120473, "networkId": 56, "outcomeId": 0, "shares": str(fill.quantity), + "value": fill.info["value"], "timestamp": 1792900000, "txId": order.id} + history = fill_of_action(row, amm=True) + [position] = positions_of([{"marketId": 120473, "networkId": 56, "executionMode": 0, "outcomeId": 0, + "shares": str(fill.quantity), "price": 0.65, "status": "ongoing"}]) + assert history.id == fill.id and history.market_id == fill.market_id == position.market_id == MARKET_ID + assert history.price == fill.price and history.side == fill.side + + def test_one_outcome_of_many(self): + raw = copy.deepcopy(RAW) + raw["id"], raw["networkId"] = 412, 2741 + raw["outcomes"] = [{**raw["outcomes"][0], "id": k} for k in range(3)] + raw["token"] = {**raw["token"], "decimals": 6} + chain = Chain(pools=[1000, 1000, 1000], decimals=6, receipt=lambda tx: {"status": "0x1", "logs": [ + action_log(action=0, market=412, outcome=1, shares=12 * 10**6, value=4 * 10**6, + contract=sig.AMM_CONTRACTS[2741])]}) + order = run(trade(chain, request(market_id="myriad:2741-amm-412-1", quantity=Decimal("12"), + price=Decimal("0.5")), raw=raw)) + assert chain.sent[0]["chainId"] == 2741 and chain.sent[0]["to"] == HexBytes(sig.AMM_CONTRACTS[2741]) + [fill] = order.info["fills"] + assert fill.market_id == order.market_id == "myriad:2741-amm-412-1" + assert (fill.side, fill.quantity, fill.price) == (Side.BUY, Decimal("12"), Decimal("4") / Decimal("12")) + row = {"action": "buy", "marketId": 412, "networkId": 2741, "outcomeId": 1, "shares": "12", "value": "4", + "timestamp": 1792900000, "txId": order.id} + assert fill_of_action(row, amm=True, many=True).market_id == fill.market_id + rows = [{"marketId": 412, "networkId": 2741, "executionMode": 0, "outcomeId": 1, "shares": 12, "status": "ongoing"}] + assert positions_of(rows, many={(2741, 412)})[0].market_id == fill.market_id + assert positions_of(rows)[0].market_id == "myriad:2741-amm-412", "without knowing, outcome 1 reads binary" + + def test_history_and_positions_ask_for_both_kinds(self): + address = OWNER.address + many_raw = {**RAW, "id": 412, "networkId": 2741, "outcomes": [RAW["outcomes"][0]] * 3} + + def events(req): + model = req.url.params["trading_model"] + row = ({"action": "buy", "marketId": 3722, "networkId": 56, "outcomeId": 0, "shares": 2, "value": 1, + "timestamp": 1792772000, "txId": "0xob"} if model == "ob" else + {"action": "sell", "marketId": 412, "networkId": 2741, "outcomeId": 0, "shares": 2, "value": 1, + "timestamp": 1792773000, "txId": "0xamm"}) + return httpx.Response(200, json={"data": [row], "pagination": {"hasNext": model == "amm"}}) + + portfolio = {"data": [{"marketId": 412, "networkId": 2741, "executionMode": 0, "outcomeId": 0, "shares": 2, + "status": "ongoing"}], "pagination": {"hasNext": False}} + + def venue(req): + if req.url.path == f"/users/{address}/events": + return events(req) + if req.url.path == f"/users/{address}/portfolio": + return httpx.Response(200, json=portfolio) + return httpx.Response(404) + + async def go(): + async with adapter(Chain(), raw=many_raw, venue=venue) as myriad: + return await myriad.fetch_my_trades(), await myriad.fetch_positions() + + trades, [position] = run(go()) + assert [(f.id, f.market_id) for f in trades] == [("0xamm:0:sell", "myriad:2741-amm-412-0"), + ("0xob:0:buy", "myriad:56-3722")] + assert trades.next_cursor == "2" and position.market_id == "myriad:2741-amm-412-0" diff --git a/tests/test_myriad_trading.py b/tests/test_myriad_trading.py index 57c6c10..d4224ad 100644 --- a/tests/test_myriad_trading.py +++ b/tests/test_myriad_trading.py @@ -425,14 +425,21 @@ async def go(): with pytest.raises(OrderRejected): run(go()) - def test_an_amm_market_is_refused(self): + def test_an_amm_market_takes_no_resting_order_or_order_book_calldata(self): async def go(): async with adapter(Venue()) as myriad: return await myriad.create_order(request(market_id="myriad:56-amm-120473")) - with pytest.raises(InvalidOrder, match="AMM"): + with pytest.raises(InvalidOrder, match="taker-only"): run(go()) + async def calldata(): + async with adapter(Venue()) as myriad: + return await myriad.position_calldata("redeem", "myriad:56-amm-120473") + + with pytest.raises(InvalidOrder, match="AMM market"): + run(calldata()) + def test_without_a_key_the_wallet_signs_in(self): calls = [] @@ -473,9 +480,10 @@ def test_open_orders_positions_and_fills(self): f"GET /users/{OWNER.address}/portfolio": {"data": [ {"marketId": 3722, "networkId": 56, "executionMode": 1, "outcomeId": 0, "shares": 2, "price": 0.9, "value": 1.8, "profit": 0, "status": "ongoing"}], "pagination": {"hasNext": False}}, - f"GET /users/{OWNER.address}/events": {"data": [ + f"GET /users/{OWNER.address}/events": lambda r: httpx.Response(200, json={"data": [ {"action": "buy", "marketId": 3722, "networkId": 56, "outcomeId": 0, "shares": 2, "value": 1.8, - "timestamp": 1792772000, "txId": "0xt"}], "pagination": {"hasNext": False}}, + "timestamp": 1792772000, "txId": "0xt"}] if r.url.params["trading_model"] == "ob" else [], + "pagination": {"hasNext": False}}), }) async def go(): @@ -488,7 +496,9 @@ async def go(): assert venue.sent("GET", "/orders")[0].url.params["market_id"] == "3722" assert position.market_id == MARKET_ID and position.contracts == Decimal("2") assert [f.id for f in fills] == ["0xt:0:buy"] - assert venue.sent("GET", f"/users/{OWNER.address}/events")[0].url.params["since"] == "1792771000" + ob, amm = venue.sent("GET", f"/users/{OWNER.address}/events") + assert ob.url.params["since"] == "1792771000" and ob.url.params["network_id"] == "56" + assert amm.url.params["trading_model"] == "amm" and "network_id" not in amm.url.params def test_the_balance_is_the_wallets_usd1_less_open_buys(self): entry, _, _ = signed(request(price=Decimal("0.4"))) From 4c2181882cf5e9e112b8c694f93978fc62802433 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 15/19] feat: gemini-combo-rfq Co-Authored-By: Claude Opus 5.5 --- docs/capabilities.md | 14 +- docs/streaming.md | 1 + docs/trading.md | 68 ++++- synpath/__init__.py | 2 +- synpath/trading/gemini.py | 208 ++++++++++++++- synpath/ws/__init__.py | 4 +- synpath/ws/gemini.py | 347 +++++++++++++++++++++++- tests/samples/gemini_combo_rfq.json | 115 ++++++++ tests/test_gemini_combo_rfq.py | 400 ++++++++++++++++++++++++++++ tests/test_gemini_trading.py | 2 +- 10 files changed, 1145 insertions(+), 16 deletions(-) create mode 100644 tests/samples/gemini_combo_rfq.json create mode 100644 tests/test_gemini_combo_rfq.py diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..0c2c3cb 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -496,9 +496,14 @@ WebSocket takes no other kind). The account must have accepted Gemini's current Prediction Markets terms; `GeminiTrading.fetch_terms_status()` says whether it has. See [trading](trading.md#gemini). -Combo RFQ is not offered. It is a sealed one-second auction for market makers -over the WebSocket, in beta, and does not map onto the request-and-accept RFQ -the Kalshi adapter has. +Combos are instruments (`gemini:GEMI-CMB-...`) with their own books, and +`GeminiTrading` lists, looks up and creates them; Gemini's documentation says +the combo routes are not enabled in production yet, where they raise +`CombosUnavailable`. Combo RFQ is a sealed one-second auction over the +WebSocket, in beta: `GeminiRfqStream` is the maker side (watch auctions, +submit, withdraw and confirm quotes). The requester side is in the Gemini +app only, with no API, so the request-and-accept calls the Kalshi adapter +has raise `NotSupported` here. See [trading](trading.md#gemini). Matching spans venues, so no adapter claims it. `synpath.match_market(id)` and `synpath.match_event(id)` are answered by Synpath's hosted matching service @@ -539,7 +544,7 @@ onboarded firms. | `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | | `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | | `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | partial: the maker side of the combo RFQ auction (`GeminiRfqStream`); requesting is app-only | | `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; @@ -586,6 +591,7 @@ Every stream answers the `watch_*` keys the same way | `MyriadUserStream` | no | no | no | no | yes | yes | partial: one outcome's balance per update | no | | `GeminiMarketStream` | yes, differential, from a snapshot; a skipped update resubscribes | yes, top of book and last trade | yes | yes, contract status and strikes | no | no | no | no | | `GeminiUserStream` | no | no | no | no | yes | yes, each execution with its fee | yes, settlement payouts included | yes | +| `GeminiRfqStream` | no | no | no | no | no | no | no | no | The exchange API's other gRPC streams carry one thing each and say so: drop copy and trade capture carry fills, position and position-change carry diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..eed8e71 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -53,6 +53,7 @@ asyncio.run(main()) | `MyriadUserStream` | `ws.myriadprotocol.com/ws` | the wallet's address, and a subscription token per account channel (`MyriadTrading.subscription_token`) | orders; fills from the wallet's public tape; share balances | | `GeminiMarketStream` | `ws.gemini.com` (or the sandbox) | none | differential books in YES space from a snapshot, top of book with the last trade, public prints, contract status and strikes | | `GeminiUserStream` | `ws.gemini.com` | an account-scoped Gemini API key with a time-based nonce (the handshake is signed) | orders; each execution with its fee and maker flag; positions, settlement payouts included; balances | +| `GeminiRfqStream` | `ws.gemini.com` (or the sandbox) | none to watch auctions; the same account key to watch deliveries and quote | combo RFQ auctions as they open and end; this account's deliveries (winning quote, acceptance, outcome), deduplicated; quotes submitted, withdrawn and confirmed ([trading](trading.md#gemini)) | Each stream's `has` answers the `watch_*` capability keys (`watch_order_book`, `watch_ticker`, `watch_trades`, `watch_market_status`, `watch_orders`, diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..eb7691c 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -879,6 +879,68 @@ Fees follow the contract's listing date and are rounded up to the cent; see [Gemini](capabilities.md#gemini). `fetch_fee_estimate` reads the contract's schedule with one public request. -Combo RFQ (Gemini's sealed WebSocket auction for multi-leg combos, in beta -and run by market makers) is not offered, and nor are WebSocket order -placement and `cancelOnDisconnect`. +A combo (two to six contracts, each on a YES or NO outcome, settling YES +only if every leg does) is its own instrument, `gemini:GEMI-CMB-...`, with +its own book, orders and positions: `create_order`, `fetch_orders` and +`fetch_positions` take it as they take any contract. `fetch_combos` and +`fetch_combo` (public) list combos and look one up with its legs, each leg +Gemini's numeric contract id with the outcome the combo needs from it; +any listed contract can be a leg. `create_combo([("101", "yes"), ("202", +"no")])` returns the canonical combo for a set of legs, creating it if +Gemini has none (signed; it places nothing). Gemini's documentation says +these combo routes are not enabled in production yet: there they raise +`CombosUnavailable`, carrying Gemini's answer. The sandbox +(`GEMINI_ENV=sandbox`) has them. + +Combo RFQ is a sealed auction on Gemini's WebSocket, in beta. A requester +opens an RFQ and `requestForQuote` broadcasts it, anonymously, with its legs +and its size in contracts or USD. Makers each send one immutable, +single-sided quote (the price they sell the combo's YES at) within one +second. At the close only the best eligible quote (lowest price, then +earliest) is disclosed, privately, to the requester, who has five seconds to +accept. The winning maker then has one second to confirm or decline (its +last look). The requester side, opening an RFQ and accepting its quote, is +in the Gemini app and website only, and Gemini publishes no API for it, so +`create_rfq` and `accept_quote` raise `NotSupported`. + +The maker side is `synpath.ws.gemini.GeminiRfqStream`: + +```python +from synpath.ws.gemini import GeminiRfqStream + +async with GeminiRfqStream(creds) as stream: # GEMINI_ENV=sandbox for the sandbox + await stream.watch_rfqs() # public, anonymous + await stream.watch_deliveries() # this account's acceptances and outcomes + async for event in stream: + if getattr(event, "name", None) == "rfq" and event.payload["state"] == "OPEN": + await stream.submit_quote(event.payload["rfq_id"], "0.55", "100") + elif getattr(event, "name", None) == "rfq_delivery" and event.payload["transition"] == "ACCEPTED": + await stream.confirm_quote(event.payload["rfq_id"], event.payload["quote_id"], True) +``` + +| Request, quotes, accept | Gemini | +|---|---| +| Ask for a price | the requester opens an RFQ in the Gemini app; no API | +| Quotes arrive | sealed: none while the window is open; at close one `rfq_delivery` with `transition="CLOSED"` and the winning quote (`quote_id`, `price`, `size`), to the requester only | +| Quote expires | five seconds after the close (`PENDING_ACCEPTANCE`), or at the RFQ's `expires_at` if sooner; `valid_until` only decides eligibility at the close | +| Accept | the requester, in the app; no API | +| Maker confirms | `confirm_quote(rfq_id, quote_id, True or False)` within one second of the `ACCEPTED` delivery | +| Fill, position | the RFQ ends `FINALIZED` (`fill_quantity` is the requester's); the trade is an order on the combo's instrument, on `GeminiUserStream` and `fetch_positions` | + +Quotes are checked before sending: a price on the cent tick inside (0, 1), a +quantity on the hundredth-contract grid that must cover the whole request +to win (`quantity * price >= notional` on a USD-sized RFQ), a `client_id` +of at most 36 printable ASCII characters (it comes back as the maker fill's +client order id), and an RFQ the stream saw close is refused. A quote +method is sent only on a live connection and never resent: no answer, or a +connection lost before one, is an unknown outcome to reconcile on the +delivery stream. Gemini's errors come back typed with its code (`-2010`, a +quote it refused, is `OrderRejected`). Deliveries are at least once and are +deduplicated by Gemini's delivery id. The maker's order rests on the book +before the requester's fill-or-kill order arrives, so it can fill in part, +or not at all, whatever the RFQ's outcome: read the maker's fills from +`GeminiUserStream`, not from the RFQ's state. Quoting needs an +account-scoped key on the signed handshake and an account Gemini has made +eligible; `withdraw_quote` also needs the session's `cancel_orders`. + +WebSocket order placement and `cancelOnDisconnect` are not offered. diff --git a/synpath/__init__.py b/synpath/__init__.py index ff88c20..37e878d 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -157,7 +157,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", "GeminiRfqStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") diff --git a/synpath/trading/gemini.py b/synpath/trading/gemini.py index 5edfe89..fd5bd92 100644 --- a/synpath/trading/gemini.py +++ b/synpath/trading/gemini.py @@ -33,6 +33,18 @@ **Fills.** The REST API has no fills endpoint. `fetch_my_trades` reads one aggregate fill per order from the order history; the account stream (`synpath.ws.gemini.GeminiUserStream`) delivers each execution. + +**Combos and their RFQ.** A combo (two to six contracts, each on a YES or NO +outcome, settling YES only if every leg does) is its own instrument, +`GEMI-CMB-...`, with its own book: `create_order`, the order history and +`fetch_positions` take it like any contract. `fetch_combos`, `fetch_combo` +and `create_combo` are Gemini's combo routes, which its documentation says +are not enabled in production yet; there they fail with `CombosUnavailable` +carrying Gemini's own answer. Combo RFQ is a sealed one-second auction run +on the WebSocket. Gemini publishes the maker side +(`synpath.ws.gemini.GeminiRfqStream`); the requester side, opening an RFQ +and accepting its winning quote, is in the Gemini app and website only, with +no API, so `create_rfq` and `accept_quote` raise `NotSupported`. """ from __future__ import annotations @@ -47,7 +59,7 @@ from .. import ids from ..base import AsyncHttpClient, Capability -from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, RateLimitExceeded +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported, RateLimitExceeded from ..gemini import ( API_URL, SANDBOX_URL, candidate_event_tickers, fee_schedule_of, native_symbol, parse_ts, ) @@ -394,6 +406,129 @@ def balance_of(rows: list[dict[str, Any]], *, account: Account, currency: str = ) +# --------------------------------------------------------------------------- +# Combos +# --------------------------------------------------------------------------- + +COMBO_LEGS = (2, 6) +"""Fewest and most legs a combo takes.""" + +COMBO_PAGE = 50 +"""Combos per listing page unless `limit` says otherwise.""" + +RFQ_APP_ONLY = ( + f"{VENUE}: combo RFQs are opened, and their winning quote accepted, in the Gemini app and website; Gemini " + f"publishes no API for the requester side (developer.gemini.com/prediction-markets/combos-rfq/maker-integration). " + f"Trade a combo on its own book with create_order on its GEMI-CMB symbol, or quote RFQs as a maker with " + f"synpath.ws.gemini.GeminiRfqStream" +) + + +class CombosUnavailable(ExchangeError): + """Gemini answered that combos are not available on this host. Its + documentation says the combo routes are not enabled in production yet; + `body` and `status` are Gemini's own answer. The sandbox has them.""" + + +def _outcome_word(value: Any) -> str: + text = str(value or "").strip().lower() + if text not in ("yes", "no"): + raise InvalidOrder(f"{VENUE}: a combo leg's outcome is yes or no, got {value!r}") + return text + + +def _instrument(value: Any) -> str | None: + """A Synpath id for a contract ticker that is an instrument symbol.""" + text = native_symbol(str(value or "")) + return ids.qualify(VENUE, text) if text.startswith("GEMI-") else None + + +def combo_legs_of(legs: list[Any]) -> list[dict[str, str]]: + """`create_combo` legs as the wire's `[{"contractId", "requiredOutcome"}]`. + + A leg is a `(contract_id, outcome)` pair or a dict with `contract_id` (or + the wire's `contractId`) and `outcome` (or `requiredOutcome`). The id is + Gemini's numeric contract id (a combo leg's `contractId`, an RFQ leg's + `c`), not an instrument symbol. Two to six legs, each contract once, + checked before anything is signed.""" + out: list[dict[str, str]] = [] + for leg in legs: + if isinstance(leg, dict): + contract, outcome = leg.get("contract_id", leg.get("contractId")), leg.get("outcome", leg.get("requiredOutcome")) + else: + contract, outcome = leg + contract = str(contract or "").strip() + if not contract.isdigit(): + raise InvalidOrder(f"{VENUE}: a combo leg names Gemini's numeric contract id, got {contract!r}") + out.append({"contractId": contract, "requiredOutcome": _outcome_word(outcome).capitalize()}) + low, high = COMBO_LEGS + if not low <= len(out) <= high: + raise InvalidOrder(f"{VENUE}: a combo takes {low} to {high} legs, got {len(out)}") + if len({leg["contractId"] for leg in out}) != len(out): + raise InvalidOrder(f"{VENUE}: a combo names each contract once") + return out + + +def combo_of(raw: dict[str, Any]) -> dict[str, Any]: + """A combo as one shape, from either of Gemini's: a listing or lookup row + (`{"contract", "legs"}`) or a create answer's `combo` (`ComboSummary`). + + `market_id` is the combo's own instrument (`gemini:GEMI-CMB-...`), `None` + while Gemini has not registered one; each leg carries its `contract_id`, + the `outcome` the combo needs from it (`yes` / `no`), its own `market_id` + where Gemini names its symbol, and `result` once it has resolved.""" + contract = raw.get("contract") if isinstance(raw.get("contract"), dict) else None + if contract is not None: + symbol = contract.get("contractTicker") + combo_id, title, status = contract.get("contractId"), contract.get("contractName"), contract.get("contractStatus") + registered = _instrument(symbol) is not None + expiry = contract.get("expiryDate") + else: + symbol = raw.get("instrumentSymbol") + combo_id, title, status = raw.get("id"), raw.get("displayName"), raw.get("status") + registered = bool(raw.get("instrumentRegistered")) + expiry = raw.get("latestExpiryDate") + legs = [] + for leg in sorted(raw.get("legs") or [], key=lambda row: int(row.get("legIndex") or 0)): + inner = leg.get("contract") if isinstance(leg.get("contract"), dict) else {} + result = leg.get("legOutcome") + legs.append({ + "contract_id": str(leg.get("contractId") or ""), + "outcome": _outcome_word(leg.get("requiredOutcome")), + "market_id": _instrument(inner.get("contractTicker")), + "title": inner.get("contractName"), + "result": str(result).lower() if result else None, + "resolved_at": parse_ts(leg.get("resolvedAt")), + }) + return { + "market_id": _instrument(symbol) if registered or contract is not None else None, + "combo_id": str(combo_id) if combo_id not in (None, "") else None, + "title": title, + "status": str(status).lower() if status else None, + "registered": registered, + "expires_at": parse_ts(expiry), + "legs": legs, + "info": raw, + } + + +def _combo_error(exc: ExchangeError, *, base_url: str) -> ExchangeError: + """Gemini's documented "combos unavailable" answer (a 404 with no code + and the generic `Not Found`, where a missing combo says `Combo not found` + and a missing leg `COMBO_LEG_NOT_FOUND`) as `CombosUnavailable`; anything + else as it came. A 503 stays `ExchangeNotAvailable`: temporary.""" + body = exc.body if isinstance(exc.body, dict) else {} + if not (exc.status == 404 and not body.get("code") and str(body.get("message") or "") == "Not Found"): + return exc + host = "production" if base_url.rstrip("/") == API_URL.rstrip("/") else base_url + said = " ".join(str(part) for part in (exc.status, body.get("error"), body.get("message")) if part) + return CombosUnavailable( + f"{VENUE}: combos are not available on {host} (Gemini answered {said or exc}); Gemini's documentation says " + f"the combo routes are not enabled in production yet; the sandbox (env sandbox) has them", + body=exc.body, status=exc.status, + ) + + def _code(body: Any) -> tuple[str, str]: """(the venue's error code, its message) from Gemini's error payloads: `{"result": "error", "reason", "message"}` (authentication), @@ -480,8 +615,9 @@ class GeminiTrading(TradingExchange): "fetch_settlements": True, "fetch_queue_position": False, "fetch_fee_estimate": True, - # Combo RFQ is a WebSocket auction for market makers, in beta; not offered. - "rfq": False, + # Combo RFQ is a sealed WebSocket auction: the maker side is + # `synpath.ws.gemini.GeminiRfqStream`; requesting is app-only. + "rfq": "partial", "split_merge": False, # The account streams are `synpath.ws.gemini.GeminiUserStream`. "watch_orders": False, @@ -782,6 +918,72 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency="USD", info={"schedule": schedule.model_dump()}, ) + # -- combos and RFQ --------------------------------------------------------- + + async def _combos(self, path: str, *, params: dict[str, Any] | None = None, fields: Any = None, + write: bool = False) -> Any: + try: + if write: + return await self._call(path, fields, kind="write") + return await self._public(path, params) + except ExchangeError as exc: + raise _combo_error(exc, base_url=self.base_url) from None + + async def fetch_combos( + self, *, status: str | None = None, contract_id: str | None = None, + registered: bool | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[dict[str, Any]]: + """Combos (public), `Active` ones unless `status` says otherwise + (`Settled`, `Voided`: Gemini's words), each with its legs (`combo_of`). + `contract_id` keeps those with that contract as a leg; `registered` + those with (or without) an instrument to trade. Pages by offset. + Any listed contract can be a leg: there is no separate eligibility + list. Not enabled in production yet (`CombosUnavailable`).""" + wanted = max(1, limit or COMBO_PAGE) + offset = int(cursor) if cursor and cursor.isdigit() else 0 + if cursor and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params = { + "status": status, "contractId": contract_id, + "instrumentRegistered": None if registered is None else str(registered).lower(), + "limit": wanted, "offset": offset or None, + } + raw = await self._combos(f"{PREFIX}/combos", params={k: v for k, v in params.items() if v is not None}) or {} + rows = raw.get("combos") or [] + total = (raw.get("pagination") or {}).get("total") + more = offset + len(rows) < int(total) if total is not None else len(rows) >= wanted + return Page([combo_of(row) for row in rows], next_cursor=str(offset + len(rows)) if more and rows else None) + + async def fetch_combo(self, market_id: str) -> dict[str, Any]: + """One combo by its instrument (`gemini:GEMI-CMB-...`), with its legs + and how each has resolved (public).""" + symbol = symbol_of(market_id) + try: + raw = await self._combos(f"{PREFIX}/combos/{symbol}") + except MarketNotFound as exc: + raise MarketNotFound(f"{VENUE}: no combo {symbol}", body=exc.body, status=exc.status) from None + return combo_of(raw or {}) + + async def create_combo(self, legs: list[Any]) -> dict[str, Any]: + """The canonical combo for these legs, created if Gemini has none: + the same set of legs in any order answers the same combo + (`info["alreadyExisted"]`). Legs name Gemini's numeric contract ids + (`combo_legs_of`). Signed; needs the key's `PredictionsNewOrder` + permission. It places nothing: trade the combo with `create_order` on + its `market_id`. Not enabled in production yet (`CombosUnavailable`).""" + raw = await self._combos(f"{PREFIX}/combos", fields={"legs": combo_legs_of(legs)}, write=True) or {} + combo = combo_of(raw.get("combo") or {}) + return {**combo, "info": raw} + + async def create_rfq(self, *args: Any, **kwargs: Any) -> Any: + """Not offered by Gemini's API: see `RFQ_APP_ONLY`.""" + raise NotSupported(RFQ_APP_ONLY) + + async def accept_quote(self, *args: Any, **kwargs: Any) -> Any: + """Not offered by Gemini's API: see `RFQ_APP_ONLY`. A requester's + winning quote still arrives on `GeminiRfqStream.watch_deliveries()`.""" + raise NotSupported(RFQ_APP_ONLY) + async def _contract(self, symbol: str) -> dict[str, Any]: """The public contract record for a symbol, found through its event.""" for ticker in candidate_event_tickers(symbol): diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index a9d7c38..968b449 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -71,7 +71,7 @@ def __getattr__(name: str): if name == "AzuroMarketStream": from .azuro import AzuroMarketStream return AzuroMarketStream - if name in ("GeminiMarketStream", "GeminiUserStream"): + if name in ("GeminiMarketStream", "GeminiUserStream", "GeminiRfqStream"): from . import gemini return getattr(gemini, name) if name in ("HyperliquidMarketStream", "HyperliquidUserStream"): @@ -93,5 +93,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", "GeminiRfqStream", ] diff --git a/synpath/ws/gemini.py b/synpath/ws/gemini.py index a10b0cd..5f1175c 100644 --- a/synpath/ws/gemini.py +++ b/synpath/ws/gemini.py @@ -26,24 +26,46 @@ fields. Fills ride on `FILLED` and `PARTIALLY_FILLED` frames (`Z` the execution's quantity, `L` its price, `n` its fee). Nothing is replayed after a reconnect, so one is reported with `reconcile_required`. + +**Combo RFQ, the maker side** (`GeminiRfqStream`). Gemini runs combo RFQ as +a sealed auction on this connection: a requester opens one in the Gemini app +(there is no API for that), `requestForQuote` broadcasts it to everyone, +makers answer with `rfq.submit_quote` inside a one-second window (`w`), and +at close only the best eligible quote is disclosed, privately, to the +requester, who has five seconds to accept. The winning maker then has one +second for its last look (`rfq.confirm_quote`). Quotes are single-sided (the +price the combo's YES is sold at), immutable, one per account per RFQ, and +withdrawing one does not free the slot. The private deliveries +(`requestForQuote@account`) are at least once and keyed by a delivery id, +which this stream deduplicates. Quote methods are never queued for a later +connection and never resent: an answer that does not come is an unknown +outcome to reconcile on the delivery stream. """ from __future__ import annotations +import asyncio +import time from collections import OrderedDict from decimal import Decimal from typing import Any from .. import ids from ..base import Capability +from ..errors import ( + AuthenticationError, BadRequest, ExchangeError, ExchangeNotAvailable, NetworkError, RateLimitExceeded, + RequestTimeout, +) from ..gemini import native_symbol from ..trading.credentials import GeminiCredentials -from ..trading.gemini import WS_STATUS, GeminiSigner, yes_leg, yes_price +from ..trading.errors import CredentialsMissing, InvalidOrder, OrderRejected, PermissionDenied +from ..trading.gemini import DEFAULT_PRECISION, WS_STATUS, GeminiSigner, yes_leg, yes_price +from ..trading.money import D, validate_price, validate_quantity from ..trading.types import ( Account, Balance, Fill, Liquidity, Order, OrderStatus, OrderType, Position, PositionSide, Side, TimeInForce, ) from .base import ( BalanceEvent, BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, - QuoteEvent, Stream, TradeEvent, maybe_D, + QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, ) VENUE = "gemini" @@ -538,3 +560,324 @@ def position_of_row(row: dict[str, Any], *, account: Account | None = None, time payout=payout, timestamp=timestamp, info={**row, "result": result if result in ("yes", "no") else None}, ) + + +# --------------------------------------------------------------------------- +# Combo RFQ (maker side) +# --------------------------------------------------------------------------- + +RFQ_STATES = frozenset({"OPEN", "PENDING_ACCEPTANCE", "CONFIRMING", "FINALIZING", "FINALIZED", "CANCELLED", + "EXPIRED", "FAILED"}) +RFQ_TERMINAL = frozenset({"FINALIZED", "CANCELLED", "EXPIRED", "FAILED"}) +"""An RFQ in one of these is over. Only `FINALIZED` traded.""" +DELIVERY_SCOPES = ("account", "session") +DELIVERY_MEMORY = 10_000 +"""Private delivery ids remembered for deduplication, most recent first.""" +RFQ_MEMORY = 10_000 +"""Public RFQs whose last state the stream keeps.""" +CLIENT_ID_MAX = 36 + +WS_ERRORS: dict[int, type[ExchangeError] | type[NetworkError]] = { + -1002: AuthenticationError, + -1004: PermissionDenied, + -1013: BadRequest, + -2010: OrderRejected, + -1003: RateLimitExceeded, + -1101: ExchangeNotAvailable, +} +"""Gemini's WebSocket error codes for the quote methods. -1000 (internal +error) and anything unlisted is an `ExchangeError`; its outcome is unknown.""" + + +def rfq_of(event: dict[str, Any]) -> dict[str, Any]: + """A public `requestForQuote` event: the auction and its state, never a + quote or a participant. `market_id` is the combo's own instrument where + Gemini names it; each leg is Gemini's contract id (`c`) with the outcome + the combo takes on it, and the leg's own instrument where named. Exactly + one of `notional` (USD) and `quantity` (contracts) sizes it; + `fill_quantity` appears once the requester accepts and counts only on + `FINALIZED`. Times are milliseconds: `quote_deadline` (`w`, the end of + the one-second quoting window), `expires_at` (`x`), `created_at` (`c`).""" + symbol = event.get("s") + return { + "rfq_id": str(event.get("r") or ""), + "market_id": ids.qualify(VENUE, native_symbol(str(symbol))) if symbol else None, + "legs": [ + { + "contract_id": str(leg.get("c") or ""), + "outcome": outcome_word(leg.get("o")), + "market_id": ids.qualify(VENUE, native_symbol(str(leg["s"]))) if leg.get("s") else None, + } + for leg in event.get("l") or [] if isinstance(leg, dict) + ], + "notional": maybe_D(event.get("n")), + "quantity": maybe_D(event.get("q")), + "fill_quantity": maybe_D(event.get("f")), + "state": str(event.get("S") or ""), + "quote_deadline": event.get("w"), + "expires_at": event.get("x"), + "created_at": event.get("c"), + "timestamp": event.get("E"), + "info": event, + } + + +def delivery_of(event: dict[str, Any]) -> dict[str, Any]: + """A private `requestForQuote@account` delivery. `transition` is what + happened: `CLOSED` discloses the winning quote to the requester, + `ACCEPTED` tells the winning maker to confirm within a second, then + `CONFIRMED` / `DECLINED` and `FINALIZED` / `FAILED`. The quote fields + (`quote_id`, `price`, `size`, `quote_status`, `valid_until`) are absent on + an RFQ-level delivery. `delivery_id` is Gemini's idempotency key.""" + return { + "delivery_id": str(event.get("i") or ""), + "rfq_id": str(event.get("r") or ""), + "transition": str(event.get("x") or ""), + "state": str(event.get("S") or ""), + "quote_id": event.get("q"), + "price": maybe_D(event.get("p")), + "size": maybe_D(event.get("sz")), + "quote_status": event.get("qs"), + "valid_until": event.get("vu"), + "timestamp": event.get("E"), + "info": event, + } + + +def quote_params(rfq_id: str, price: Any, quantity: Any, *, valid_until: int | None = None, + client_id: str | None = None, now_ms: int | None = None) -> dict[str, Any]: + """`rfq.submit_quote` params, checked before anything is sent: a price on + the cent tick strictly inside (0, 1), a positive quantity on the + hundredth-contract grid (an RFQ's grid is whole contracts or hundredths; + the venue refuses what is off its own), `valid_until` in the future, a + `client_id` of at most 36 printable ASCII characters.""" + if not str(rfq_id or "").strip(): + raise InvalidOrder(f"{VENUE}: rfq_id is required") + price_d = validate_price(D(price), DEFAULT_PRECISION) + quantity_d = validate_quantity(D(quantity), DEFAULT_PRECISION) + params: dict[str, Any] = {"rfqId": str(rfq_id), "price": format(price_d, "f"), "quantity": format(quantity_d, "f")} + if valid_until is not None: + if isinstance(valid_until, bool) or not isinstance(valid_until, int): + raise InvalidOrder(f"{VENUE}: valid_until is a Unix time in milliseconds, got {valid_until!r}") + if valid_until <= (now_ms if now_ms is not None else int(time.time() * 1000)): + raise InvalidOrder(f"{VENUE}: valid_until must be in the future") + params["validUntil"] = valid_until + if client_id is not None: + if not client_id or len(client_id) > CLIENT_ID_MAX or not all(" " <= ch <= "~" for ch in client_id): + raise InvalidOrder(f"{VENUE}: client_id is 1 to {CLIENT_ID_MAX} printable ASCII characters") + params["clientId"] = client_id + return params + + +def ws_error(message: dict[str, Any], *, method: str) -> Exception: + """A WebSocket error answer as the typed error, Gemini's code and text kept.""" + error = message.get("error") if isinstance(message.get("error"), dict) else {} + code, text = error.get("code"), str(error.get("msg") or "") + label = f"{VENUE}: {method} refused: {code} {text}".rstrip() + kind = WS_ERRORS.get(code) if isinstance(code, int) else None + if kind is RateLimitExceeded: + return RateLimitExceeded(label) + if kind is OrderRejected: + return OrderRejected(label, reason=str(code), info=message, body=message, status=message.get("status")) + if kind is not None: + return kind(label, body=message, status=message.get("status")) # type: ignore[call-arg] + return ExchangeError(f"{label} (outcome unknown: reconcile before acting again)", body=message, + status=message.get("status")) + + +class GeminiRfqStream(_GeminiStream): + """Gemini's combo RFQ auctions, and quoting them as a market maker. + + Discovery (`watch_rfqs`) is public. Deliveries (`watch_deliveries`) and + the quote methods need an account-scoped key with a time-based nonce, the + handshake signed; Gemini also requires an account eligible to quote. + + ```python + async with GeminiRfqStream(creds) as stream: + await stream.watch_rfqs() + await stream.watch_deliveries() + async for event in stream: + if getattr(event, "name", None) == "rfq" and event.payload["state"] == "OPEN": + answer = await stream.submit_quote(event.payload["rfq_id"], "0.55", "100") + elif getattr(event, "name", None) == "rfq_delivery" and event.payload["transition"] == "ACCEPTED": + await stream.confirm_quote(event.payload["rfq_id"], event.payload["quote_id"], True) + ``` + + Events are `VenueEvent`s: `name="rfq"` with `rfq_of(...)` as the payload, + `name="rfq_delivery"` with `delivery_of(...)`, each delivery once. + """ + + name = "rfq" + has: dict[str, Capability] = {"rfq": True} + + def __init__(self, credentials: GeminiCredentials | None = None, *, sandbox: bool = False, url: str | None = None, + signer: GeminiSigner | None = None, reply_timeout: float = 5.0, clock: Any = time.time, + **kwargs: Any): + sandboxed = sandbox or (credentials is not None and credentials.env == "sandbox") + super().__init__(url or (SANDBOX_WS_URL if sandboxed else WS_URL), **kwargs) + if signer is None and credentials is not None: + signer = GeminiSigner(credentials.api_key, credentials.api_secret) + self.signer = signer + self.private = signer is not None + self.reply_timeout = reply_timeout + self._clock = clock + self.rfqs: OrderedDict[str, dict[str, Any]] = OrderedDict() + """RFQ id -> its last public state (`rfq_of`).""" + self.quotes: dict[str, str] = {} + """RFQ id -> the id of the quote this account made on it, this session.""" + self._seen: OrderedDict[str, None] = OrderedDict() + self._pending: dict[int, tuple[str, asyncio.Future]] = {} + + def headers(self) -> dict[str, str]: + return self.signer.ws_headers() if self.signer is not None else {} + + def _need_key(self, what: str) -> None: + if self.signer is None: + raise CredentialsMissing(f"{VENUE}: {what} needs an account-scoped API key (GEMINI_API_KEY, " + f"GEMINI_API_SECRET) on the connection's handshake") + + async def watch_rfqs(self) -> None: + """Every RFQ auction as it opens and ends (public, anonymous). Not + replayed after a reconnect: auctions last seconds.""" + await self._subscribe(["requestForQuote"]) + + async def watch_deliveries(self, scope: str = "account") -> None: + """This account's private RFQ deliveries: as a maker, the acceptance + of its quote and the outcome; as a requester (an RFQ opened in the + Gemini app), the winning quote and the outcome. `scope` is `account` + or `session`, the same deliveries; a connection takes one of them.""" + self._need_key("watch_deliveries") + if scope not in DELIVERY_SCOPES: + raise BadRequest(f"{VENUE}: scope is account or session, got {scope!r}") + other = f"requestForQuote@{'session' if scope == 'account' else 'account'}" + if other in self.streams: + raise BadRequest(f"{VENUE}: {other} is already watched; a connection takes one delivery scope") + await self._subscribe([f"requestForQuote@{scope}"]) + + # -- quote methods ---------------------------------------------------------- + + async def submit_quote(self, rfq_id: str, price: Any, quantity: Any, *, valid_until: int | None = None, + client_id: str | None = None) -> dict[str, Any]: + """Quote an open RFQ: sell its combo's YES at `price` a contract, for + up to `quantity` contracts (which must cover the whole request to be + eligible: `quantity * price >= notional` on a notional-sized RFQ). + Strictly before the RFQ's `quote_deadline`; immutable; one per account + per RFQ. `valid_until` (ms) defaults to the deadline. `client_id` + comes back as the maker fill's client order id. Returns Gemini's + `{"rfqId", "quoteId"}`.""" + self._need_key("submit_quote") + now = int(self._clock() * 1000) + params = quote_params(rfq_id, price, quantity, valid_until=valid_until, client_id=client_id, now_ms=now) + known = self.rfqs.get(str(rfq_id)) + if known is not None: + deadline = known.get("quote_deadline") + if known.get("state") != "OPEN" or (deadline is not None and int(deadline) <= now): + raise InvalidOrder(f"{VENUE}: RFQ {rfq_id} is no longer quotable ({known.get('state')}, " + f"window closed at {deadline})") + result = await self._request("rfq.submit_quote", params) + if result.get("quoteId"): + self.quotes[str(rfq_id)] = str(result["quoteId"]) + return result + + async def withdraw_quote(self, rfq_id: str, quote_id: str) -> dict[str, Any]: + """Pull this account's quote before the window closes. Final: the + quote cannot come back and the RFQ takes no other from this account. + Needs the session's `cancel_orders` capability.""" + self._need_key("withdraw_quote") + if not str(rfq_id or "").strip() or not str(quote_id or "").strip(): + raise InvalidOrder(f"{VENUE}: rfq_id and quote_id are required") + return await self._request("rfq.withdraw_quote", {"rfqId": str(rfq_id), "quoteId": str(quote_id)}) + + async def confirm_quote(self, rfq_id: str, quote_id: str, confirm: bool) -> dict[str, Any]: + """The winning maker's last look, within one second of the RFQ + entering `CONFIRMING` (the `ACCEPTED` delivery): `True` trades, + `False` declines and fails the RFQ. There is no default: Gemini + refuses an omitted decision. Confirming commits capital, and the + maker's order rests on the book first, so it can fill in part even if + the RFQ then fails; watch `GeminiUserStream` for the maker fills.""" + self._need_key("confirm_quote") + if not isinstance(confirm, bool): + raise InvalidOrder(f"{VENUE}: confirm is True or False, got {confirm!r}") + if not str(rfq_id or "").strip() or not str(quote_id or "").strip(): + raise InvalidOrder(f"{VENUE}: rfq_id and quote_id are required") + return await self._request("rfq.confirm_quote", + {"rfqId": str(rfq_id), "quoteId": str(quote_id), "confirm": confirm}) + + async def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + """Send one method on the live connection and wait for its answer. + Never queued for a later connection and never resent.""" + if self._ws is None: + raise NetworkError(f"{VENUE}: {method} not sent: the RFQ stream is not connected " + f"(a quote is never queued for a later connection)") + number = self._allocate() + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending[number] = (method, future) + try: + await self.send({"id": number, "method": method, "params": params}) + return await asyncio.wait_for(asyncio.shield(future), timeout=self.reply_timeout) + except asyncio.TimeoutError: + raise RequestTimeout(f"{VENUE}: {method} sent, no answer in {self.reply_timeout}s: outcome unknown; " + f"reconcile on the delivery stream, do not resend") from None + finally: + self._pending.pop(number, None) + + def on_disconnect(self) -> None: + for method, future in list(self._pending.values()): + if not future.done(): + future.set_exception(NetworkError( + f"{VENUE}: {method} sent, connection lost before the answer: outcome unknown; reconcile " + f"on the delivery stream, do not resend")) + self._pending.clear() + + # -- reading ---------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + number = message.get("id") + if "e" not in message and isinstance(number, int) and number in self._pending: + method, future = self._pending[number] + if not future.done(): + if message.get("status") == 200 and isinstance(message.get("result"), dict): + future.set_result(message["result"]) + elif message.get("status") == 200: + future.set_exception(ExchangeError(f"{VENUE}: {method} answered without a result", body=message, + status=200)) + else: + future.set_exception(ws_error(message, method=method)) + return [] + reply = self._reply(message) + if reply is not None: + return reply + if message.get("e") != "requestForQuote": + return [] + if "i" in message and isinstance(message.get("x"), str): + return self._on_delivery(message) + return self._on_rfq(message) + + def _on_rfq(self, message: dict[str, Any]) -> list[Event]: + rfq = rfq_of(message) + if not rfq["rfq_id"] or rfq["state"] not in RFQ_STATES: + self.status("error", f"unreadable RFQ event: {message!r}") + return [] + known = self.rfqs.get(rfq["rfq_id"]) + if known is not None: + # A later event may leave out what the opening one said (`w`, `x`, `s`). + rfq = {**known, **{k: v for k, v in rfq.items() if v not in (None, [])}} + self.rfqs[rfq["rfq_id"]] = rfq + self.rfqs.move_to_end(rfq["rfq_id"]) + while len(self.rfqs) > RFQ_MEMORY: + self.rfqs.popitem(last=False) + if rfq["state"] in RFQ_TERMINAL: + self.quotes.pop(rfq["rfq_id"], None) + return [VenueEvent(venue=VENUE, name="rfq", payload=rfq, timestamp=rfq["timestamp"])] + + def _on_delivery(self, message: dict[str, Any]) -> list[Event]: + delivery = delivery_of(message) + key = delivery["delivery_id"] + if key in self._seen: + return [] # at least once: the same transition again + self._seen[key] = None + while len(self._seen) > DELIVERY_MEMORY: + self._seen.popitem(last=False) + return [VenueEvent(venue=VENUE, name="rfq_delivery", payload=delivery, timestamp=delivery["timestamp"])] diff --git a/tests/samples/gemini_combo_rfq.json b/tests/samples/gemini_combo_rfq.json new file mode 100644 index 0000000..ecdd722 --- /dev/null +++ b/tests/samples/gemini_combo_rfq.json @@ -0,0 +1,115 @@ +{ + "note": "documented examples, specs/openapi/prediction-markets.yaml (Combos) and developer.gemini.com/prediction-markets/combos-rfq (websocket-streams, quote-methods, examples), 2026-10-25; nothing recorded: the combo REST routes are not enabled in production and quoting needs an eligible account", + "rest": { + "combos": { + "combos": [ + { + "contract": { + "contractId": "456", "contractName": "BTC EOY26 > $120k AND ETH EOY26 > $4k", + "contractTicker": "GEMI-CMB-0526-A7F3B2C1D4E5", "eventTicker": "GEMI-CMB-0526-A7F3B2C1D4E5", + "eventName": "BTC EOY26 > $120k AND ETH EOY26 > $4k", "category": "Combo", "contractStatus": "Active", + "eventType": "binary", "expiryDate": "2026-12-31T23:59:59Z", "resolvedAt": null, "resolutionSide": null, + "parentEventTicker": null, "startTime": null + }, + "legs": [ + { + "comboId": 456, "legIndex": 0, "contractId": "101", "requiredOutcome": "Yes", "legOutcome": null, + "resolvedAt": null, + "contract": { + "contractId": "101", "contractName": "BTC above $120,000 at year-end 2026", + "contractTicker": "GEMI-BTC-EOY26-HI120000", "eventTicker": "GEMI-BTC-EOY26", + "eventName": "Bitcoin Year-End 2026", "category": "Crypto", "contractStatus": "Active", + "eventType": "binary", "expiryDate": "2026-12-31T23:59:59Z", "resolvedAt": null, + "resolutionSide": null, "parentEventTicker": null, "startTime": null + } + }, + { + "comboId": 456, "legIndex": 1, "contractId": "202", "requiredOutcome": "Yes", "legOutcome": null, + "resolvedAt": null, + "contract": { + "contractId": "202", "contractName": "ETH above $4,000 at year-end 2026", + "contractTicker": "GEMI-ETH-EOY26-HI4000", "eventTicker": "GEMI-ETH-EOY26", + "eventName": "Ethereum Year-End 2026", "category": "Crypto", "contractStatus": "Active", + "eventType": "binary", "expiryDate": "2026-12-31T23:59:59Z", "resolvedAt": null, + "resolutionSide": null, "parentEventTicker": null, "startTime": null + } + } + ] + } + ], + "pagination": {"limit": 50, "offset": 0, "total": 1} + }, + "create_combo": { + "combo": { + "id": 456, "canonicalLegKey": "101:Yes|202:No", "legCount": 2, + "displayName": "BTC EOY26 > $120k AND ETH EOY26 <= $4k", "status": "Active", "instrumentId": 98765, + "instrumentSymbol": "GEMI-CMB-0526-A7F3B2C1D4E5", "instrumentRegistered": true, + "latestExpiryDate": "2026-12-31T23:59:59.000Z", "createdAt": "2026-05-01T12:00:00.000Z", + "updatedAt": "2026-05-01T12:00:00.000Z", + "legs": [ + {"comboId": 456, "legIndex": 0, "contractId": "101", "requiredOutcome": "Yes"}, + {"comboId": 456, "legIndex": 1, "contractId": "202", "requiredOutcome": "No"} + ] + }, + "alreadyExisted": false + }, + "errors": { + "combos_unavailable": {"error": "NOT_FOUND", "message": "Not Found"}, + "leg_not_found": {"error": "NOT_FOUND", "code": "COMBO_LEG_NOT_FOUND", + "message": "One or more contracts in this combo could not be found. Update your selection and try again."}, + "combo_not_found": {"error": "NOT_FOUND", "message": "Combo not found"}, + "validation": {"error": "InvalidInput", "code": "COMBO_VALIDATION_ERROR", "message": "a combo needs 2-6 legs"} + } + }, + "ws": { + "rfq_open": { + "e": "requestForQuote", "E": 1780000000000, "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", + "s": "GEMI-CMB-260306-A1B2C3D4E5F6G7H8-CMB", + "l": [{"c": "123456", "o": "YES", "s": "GEMI-XRPUSD-260828"}, {"c": "123789", "o": "NO"}], + "n": "50000.00", "S": "OPEN", "w": 1780000001000, "x": 1780000120000, "c": 1780000000000 + }, + "rfq_finalized": { + "e": "requestForQuote", "E": 1780000004500, "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", + "l": [{"c": "123456", "o": "YES", "s": "GEMI-XRPUSD-260828"}, {"c": "123789", "o": "NO"}], + "n": "50000.00", "f": "90909", "S": "FINALIZED", "c": 1780000000000 + }, + "delivery_closed": { + "e": "requestForQuote", "i": "b16097e9-3d7f-5910-9820-eeedf51a8be3", "E": 1780000001100, + "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "x": "CLOSED", "S": "PENDING_ACCEPTANCE", + "q": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "p": "0.55", "sz": "100000", "qs": "ACTIVE", "vu": 1780000001000 + }, + "delivery_accepted": { + "e": "requestForQuote", "i": "88bfe6b3-be05-56c6-bd1b-51b25fafb27a", "E": 1780000004100, + "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "x": "ACCEPTED", "S": "CONFIRMING", + "q": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "p": "0.55", "sz": "100000", "qs": "ACTIVE", "vu": 1780000001000 + }, + "submit_quote": { + "id": 1, "method": "rfq.submit_quote", + "params": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "price": "0.55", "quantity": "1000", + "validUntil": 1780000060000, "clientId": "maker-fill-123"} + }, + "submit_quote_response": { + "id": 1, "status": 200, + "result": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6"} + }, + "withdraw_quote": { + "id": 2, "method": "rfq.withdraw_quote", + "params": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6"} + }, + "withdraw_quote_response": { + "id": 2, "status": 200, + "result": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6"} + }, + "confirm_quote": { + "id": 3, "method": "rfq.confirm_quote", + "params": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "confirm": true} + }, + "confirm_quote_response": { + "id": 3, "status": 200, + "result": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "confirmed": true} + }, + "closed_auction_error": { + "id": 3, "status": 400, "error": {"code": -2010, "msg": "RFQ is not in a valid state for this operation"} + } + } +} diff --git a/tests/test_gemini_combo_rfq.py b/tests/test_gemini_combo_rfq.py new file mode 100644 index 0000000..25138ea --- /dev/null +++ b/tests/test_gemini_combo_rfq.py @@ -0,0 +1,400 @@ +"""Gemini combos and combo RFQ: the combo routes against a scripted venue, +the maker side of the sealed auction against a fake socket. Every payload is +one of Gemini's documented examples (`tests/samples/gemini_combo_rfq.json`): +the combo routes are not enabled in production and quoting needs an +eligible account, so nothing was recorded. Nothing here reaches Gemini.""" +from __future__ import annotations + +import asyncio +import base64 +import json +from decimal import Decimal + +import httpx +import pytest + +import synpath +from synpath.errors import AuthenticationError, BadRequest, ExchangeNotAvailable, MarketNotFound, NetworkError, \ + NotSupported, RateLimitExceeded, RequestTimeout +from synpath.trading.credentials import GeminiCredentials +from synpath.trading.errors import CredentialsMissing, InvalidOrder, OrderRejected, PermissionDenied +from synpath.trading.gemini import ( + CombosUnavailable, GeminiSigner, GeminiTrading, RFQ_APP_ONLY, combo_legs_of, combo_of, +) +from synpath.ws.base import StreamStatusEvent, VenueEvent +from synpath.ws.gemini import GeminiRfqStream, delivery_of, quote_params, rfq_of, ws_error + +from conftest import load + +SAMPLE = load("gemini_combo_rfq.json") +REST, WS = SAMPLE["rest"], SAMPLE["ws"] +SECRET = "synpath-test-secret" +KEY = "account-test0000000000000000" +CREDS = GeminiCredentials(api_key=KEY, api_secret=SECRET) +SANDBOX = GeminiCredentials(api_key=KEY, api_secret=SECRET, env="sandbox") +COMBO = "gemini:GEMI-CMB-0526-A7F3B2C1D4E5" +RFQ = "01J9Z3K7Q2N8M4P6R8T0V2W4X6" +QUOTE = "01J9Z2K7Q2N8M4P6R8T0V2W4X6" + +# Computed independently with `openssl dgst -sha384 -hmac synpath-test-secret` +# over the base64 of the payload below. +COMBO_PAYLOAD = ( + "eyJyZXF1ZXN0IjoiL3YxL3ByZWRpY3Rpb24tbWFya2V0cy9jb21ib3MiLCJub25jZSI6IjE3NzYyOTQ0NDciLCJsZWdzIjpbeyJjb250cmFj" + "dElkIjoiMTAxIiwicmVxdWlyZWRPdXRjb21lIjoiWWVzIn0seyJjb250cmFjdElkIjoiMjAyIiwicmVxdWlyZWRPdXRjb21lIjoiTm8ifV19" +) +COMBO_SIGNATURE = "677b0b807a6705f677d2a55a07d91c21f8d51361c550d0fa59cfacf71d8fdcaea8963a7ce74143923d743b98c8d965a9" + + +def run(coro): + return asyncio.run(coro) + + +def decoded(req: httpx.Request) -> dict: + return json.loads(base64.b64decode(req.headers["X-GEMINI-PAYLOAD"])) + + +# --------------------------------------------------------------------------- +# REST: combos +# --------------------------------------------------------------------------- + +class Venue: + """The combo routes as documented; `disabled` answers as production does.""" + + def __init__(self, *, disabled: bool = False): + self.disabled = disabled + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + path = req.url.path + if self.disabled and path.startswith("/v1/prediction-markets/combos"): + return httpx.Response(404, json=REST["errors"]["combos_unavailable"]) + if path == "/v1/prediction-markets/combos" and req.method == "GET": + return httpx.Response(200, json=REST["combos"]) + if path == "/v1/prediction-markets/combos" and req.method == "POST": + assert req.content == b"", "a private request sends no body" + legs = decoded(req)["legs"] + if any(leg["contractId"] == "999" for leg in legs): + return httpx.Response(404, json=REST["errors"]["leg_not_found"]) + return httpx.Response(201, json=REST["create_combo"]) + if path == "/v1/prediction-markets/combos/GEMI-CMB-0526-A7F3B2C1D4E5": + return httpx.Response(200, json=REST["combos"]["combos"][0]) + if path.startswith("/v1/prediction-markets/combos/"): + return httpx.Response(404, json=REST["errors"]["combo_not_found"]) + return httpx.Response(404, json={"error": "no route"}) + + +def trading(venue: Venue, creds: GeminiCredentials = SANDBOX, **kwargs) -> GeminiTrading: + from synpath.trading.limiter import BudgetLimiter + + return GeminiTrading(creds, client=httpx.AsyncClient(transport=httpx.MockTransport(venue)), + limiter=BudgetLimiter(read_per_second=1000, write_per_second=1000), **kwargs) + + +class TestCombos: + def test_a_listing_row_reads_as_a_combo_with_its_legs(self): + combo = combo_of(REST["combos"]["combos"][0]) + assert combo["market_id"] == COMBO and combo["combo_id"] == "456" and combo["status"] == "active" + assert [(l["contract_id"], l["outcome"], l["market_id"]) for l in combo["legs"]] == [ + ("101", "yes", "gemini:GEMI-BTC-EOY26-HI120000"), ("202", "yes", "gemini:GEMI-ETH-EOY26-HI4000")] + assert combo["legs"][0]["result"] is None and combo["expires_at"] == 1798761599000 + + def test_a_create_answer_reads_the_same_way(self): + combo = combo_of(REST["create_combo"]["combo"]) + assert combo["market_id"] == COMBO and combo["registered"] is True and combo["combo_id"] == "456" + assert [(l["contract_id"], l["outcome"]) for l in combo["legs"]] == [("101", "yes"), ("202", "no")] + unregistered = combo_of({**REST["create_combo"]["combo"], "instrumentRegistered": False, + "instrumentSymbol": None}) + assert unregistered["market_id"] is None + + def test_legs_are_checked_before_signing(self): + assert combo_legs_of([("101", "yes"), {"contract_id": "202", "outcome": "NO"}]) == [ + {"contractId": "101", "requiredOutcome": "Yes"}, {"contractId": "202", "requiredOutcome": "No"}] + with pytest.raises(InvalidOrder, match="2 to 6"): + combo_legs_of([("101", "yes")]) + with pytest.raises(InvalidOrder, match="2 to 6"): + combo_legs_of([(str(n), "yes") for n in range(1, 8)]) + with pytest.raises(InvalidOrder, match="once"): + combo_legs_of([("101", "yes"), ("101", "no")]) + with pytest.raises(InvalidOrder, match="numeric contract id"): + combo_legs_of([("GEMI-BTC-EOY26-HI120000", "yes"), ("202", "no")]) + with pytest.raises(InvalidOrder, match="yes or no"): + combo_legs_of([("101", "maybe"), ("202", "no")]) + + def test_create_combo_signs_its_legs_as_a_known_answer(self): + headers = GeminiSigner(KEY, SECRET).headers( + "/v1/prediction-markets/combos", {"legs": combo_legs_of([("101", "yes"), ("202", "no")])}, + nonce="1776294447") + assert headers["X-GEMINI-PAYLOAD"] == COMBO_PAYLOAD and headers["X-GEMINI-SIGNATURE"] == COMBO_SIGNATURE + + def test_create_combo_against_the_sandbox(self): + venue = Venue() + adapter = trading(venue, clock=lambda: 1776294447) + assert adapter.base_url == "https://api.sandbox.gemini.com" + combo = run(adapter.create_combo([("101", "yes"), ("202", "no")])) + [sent] = venue.requests + assert sent.method == "POST" and sent.headers["X-GEMINI-PAYLOAD"] == COMBO_PAYLOAD + assert sent.headers["X-GEMINI-SIGNATURE"] == COMBO_SIGNATURE + assert combo["market_id"] == COMBO and combo["info"]["alreadyExisted"] is False + + def test_listing_and_lookup_are_public_and_page_by_offset(self): + venue = Venue() + adapter = trading(venue) + page = run(adapter.fetch_combos(contract_id="101", registered=True, limit=10)) + assert [c["market_id"] for c in page] == [COMBO] and page.next_cursor is None + listing = venue.requests[0] + assert "X-GEMINI-SIGNATURE" not in listing.headers + assert dict(listing.url.params) == {"contractId": "101", "instrumentRegistered": "true", "limit": "10"} + assert run(adapter.fetch_combo(COMBO))["legs"][1]["contract_id"] == "202" + with pytest.raises(MarketNotFound, match="no combo"): + run(adapter.fetch_combo("gemini:GEMI-CMB-0526-000000000000")) + + def test_production_says_combos_are_unavailable_with_geminis_answer(self): + adapter = trading(Venue(disabled=True), creds=CREDS) + for call in (adapter.fetch_combos(), adapter.fetch_combo(COMBO), + adapter.create_combo([("101", "yes"), ("202", "no")])): + with pytest.raises(CombosUnavailable) as caught: + run(call) + assert "production" in str(caught.value) and "NOT_FOUND Not Found" in str(caught.value) + assert caught.value.status == 404 and caught.value.body == REST["errors"]["combos_unavailable"] + + def test_a_missing_leg_is_not_mistaken_for_combos_being_off(self): + with pytest.raises(MarketNotFound) as caught: + run(trading(Venue()).create_combo([("101", "yes"), ("999", "no")])) + assert not isinstance(caught.value, CombosUnavailable) + assert caught.value.body["code"] == "COMBO_LEG_NOT_FOUND" + + def test_the_requester_side_is_not_an_api(self): + adapter = trading(Venue()) + for call in (adapter.create_rfq(COMBO, quantity=Decimal("1")), adapter.accept_quote(RFQ, QUOTE)): + with pytest.raises(NotSupported, match="Gemini app and website"): + run(call) + assert "GeminiRfqStream" in RFQ_APP_ONLY + assert GeminiTrading.has["rfq"] == "partial" + + def test_a_combo_is_traded_as_its_own_instrument(self): + from synpath.trading.gemini import translate_order + from synpath.trading.types import OrderRequest, Side + + body = translate_order(OrderRequest(market_id=COMBO, side=Side.BUY, quantity=Decimal("10"), + price=Decimal("0.42"))) + assert body["symbol"] == "GEMI-CMB-0526-A7F3B2C1D4E5" and body["outcome"] == "yes" + + +# --------------------------------------------------------------------------- +# WebSocket: the maker side of the auction +# --------------------------------------------------------------------------- + +class FakeSocket: + """Records frames; answers a method with `answers[method]` when given.""" + + def __init__(self, stream: GeminiRfqStream | None = None, answers: dict | None = None): + self.sent: list = [] + self.stream = stream + self.answers = answers or {} + + async def send(self, text): + frame = json.loads(text) + self.sent.append(frame) + answer = self.answers.get(frame.get("method")) + if answer is not None and self.stream is not None: + reply = {**answer, "id": frame["id"]} + asyncio.get_running_loop().call_soon(self.stream._dispatch, json.dumps(reply)) + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +def connected(stream: GeminiRfqStream, **answers) -> FakeSocket: + socket = FakeSocket(stream, {m.replace("_", ".", 1): a for m, a in answers.items()}) + stream._ws = socket + return socket + + +class TestRfqEvents: + def test_the_public_broadcast_reads_as_an_auction(self): + rfq = rfq_of(WS["rfq_open"]) + assert rfq["rfq_id"] == RFQ and rfq["state"] == "OPEN" + assert rfq["market_id"] == "gemini:GEMI-CMB-260306-A1B2C3D4E5F6G7H8-CMB" + assert rfq["legs"] == [ + {"contract_id": "123456", "outcome": "yes", "market_id": "gemini:GEMI-XRPUSD-260828"}, + {"contract_id": "123789", "outcome": "no", "market_id": None}] + assert rfq["notional"] == Decimal("50000.00") and rfq["quantity"] is None + assert (rfq["quote_deadline"], rfq["expires_at"], rfq["created_at"]) == (1780000001000, 1780000120000, + 1780000000000) + + def test_a_private_delivery_reads_as_the_winning_quote(self): + delivery = delivery_of(WS["delivery_closed"]) + assert (delivery["transition"], delivery["state"], delivery["quote_id"]) == ("CLOSED", "PENDING_ACCEPTANCE", + QUOTE) + assert delivery["price"] == Decimal("0.55") and delivery["size"] == Decimal("100000") + assert delivery["valid_until"] == 1780000001000 and delivery["quote_status"] == "ACTIVE" + + def test_the_stream_keeps_each_auctions_last_state(self): + stream = GeminiRfqStream() + [opened] = stream.handle(WS["rfq_open"]) + [finished] = stream.handle(WS["rfq_finalized"]) + assert isinstance(opened, VenueEvent) and opened.name == "rfq" + # The terminal event leaves out `w`, `x` and `s`; what the opening one said is kept. + assert finished.payload["state"] == "FINALIZED" and finished.payload["fill_quantity"] == Decimal("90909") + assert finished.payload["quote_deadline"] == 1780000001000 + assert finished.payload["market_id"] == "gemini:GEMI-CMB-260306-A1B2C3D4E5F6G7H8-CMB" + + def test_deliveries_are_at_least_once_and_are_deduplicated(self): + stream = GeminiRfqStream(CREDS) + assert [e.name for e in stream.handle(WS["delivery_closed"])] == ["rfq_delivery"] + assert stream.handle(WS["delivery_closed"]) == [] + [accepted] = stream.handle(WS["delivery_accepted"]) + assert accepted.payload["transition"] == "ACCEPTED" and accepted.payload["state"] == "CONFIRMING" + + def test_an_unreadable_rfq_is_a_status_not_a_crash(self): + stream = GeminiRfqStream() + assert stream.handle({**WS["rfq_open"], "S": "SOMETHING"}) == [] + [status] = drain(stream) + assert isinstance(status, StreamStatusEvent) and status.state == "error" + + +class TestRfqSubscriptions: + def test_it_is_exported_and_discovery_is_public(self): + assert synpath.GeminiRfqStream is GeminiRfqStream + stream = GeminiRfqStream() + assert stream.url == "wss://ws.gemini.com" and stream.headers() == {} and stream.private is False + assert GeminiRfqStream(sandbox=True).url == "wss://ws.sandbox.gemini.com" + assert GeminiRfqStream(SANDBOX).url == "wss://ws.sandbox.gemini.com" + + async def go(): + socket = connected(stream) + await stream.watch_rfqs() + return socket.sent + + assert run(go()) == [{"id": 1, "method": "SUBSCRIBE", "params": ["requestForQuote"]}] + + def test_deliveries_need_a_key_on_the_signed_handshake(self): + with pytest.raises(CredentialsMissing): + run(GeminiRfqStream().watch_deliveries()) + stream = GeminiRfqStream(CREDS, signer=GeminiSigner(KEY, SECRET, clock=lambda: 1776294447)) + assert stream.private is True + headers = stream.headers() + assert headers["X-GEMINI-NONCE"] == "1776294447" and headers["X-GEMINI-PAYLOAD"] == "MTc3NjI5NDQ0Nw==" + assert headers["X-GEMINI-SIGNATURE"] == ( + "37e6fd382f7ec4cf24501d5ebf569a27e73d63fe0cc2a613c82611b9fd83b10d0ec51f4abcc686bb160c9cf9f72104fd") + + def test_a_connection_takes_one_delivery_scope(self): + async def go(): + stream = GeminiRfqStream(CREDS) + socket = connected(stream) + await stream.watch_deliveries() + with pytest.raises(BadRequest, match="one delivery scope"): + await stream.watch_deliveries("session") + with pytest.raises(BadRequest, match="account or session"): + await stream.watch_deliveries("all") + return socket.sent + + assert run(go()) == [{"id": 1, "method": "SUBSCRIBE", "params": ["requestForQuote@account"]}] + + +class TestQuoteMethods: + def test_submit_sends_the_documented_frame_and_returns_the_quote_id(self): + async def go(): + stream = GeminiRfqStream(CREDS, clock=lambda: 1780000000.5) + stream.handle(WS["rfq_open"]) # open, its window closing at 1780000001000 + socket = connected(stream, rfq_submit_quote=WS["submit_quote_response"]) + result = await stream.submit_quote(RFQ, "0.55", "1000", valid_until=1780000060000, + client_id="maker-fill-123") + return stream, socket.sent, result + + stream, sent, result = run(go()) + [frame] = sent + assert {k: v for k, v in frame.items() if k != "id"} == {k: v for k, v in WS["submit_quote"].items() if k != "id"} + assert result == WS["submit_quote_response"]["result"] and stream.quotes[RFQ] == QUOTE + + def test_withdraw_and_confirm_send_the_documented_frames(self): + async def go(): + stream = GeminiRfqStream(CREDS) + socket = connected(stream, rfq_withdraw_quote=WS["withdraw_quote_response"], + rfq_confirm_quote=WS["confirm_quote_response"]) + withdrawn = await stream.withdraw_quote(RFQ, QUOTE) + confirmed = await stream.confirm_quote(RFQ, QUOTE, True) + return socket.sent, withdrawn, confirmed + + sent, withdrawn, confirmed = run(go()) + assert [{k: v for k, v in f.items() if k != "id"} for f in sent] == [ + {k: v for k, v in WS[name].items() if k != "id"} for name in ("withdraw_quote", "confirm_quote")] + assert withdrawn["quoteId"] == QUOTE and confirmed["confirmed"] is True + + def test_the_last_look_needs_an_explicit_decision(self): + stream = GeminiRfqStream(CREDS) + connected(stream) + with pytest.raises(InvalidOrder, match="True or False"): + run(stream.confirm_quote(RFQ, QUOTE, None)) # type: ignore[arg-type] + + def test_a_quote_is_checked_before_it_is_sent(self): + assert quote_params(RFQ, Decimal("0.55"), 100) == {"rfqId": RFQ, "price": "0.55", "quantity": "100"} + for bad in ({"price": "0.1234"}, {"price": "1"}, {"price": "0"}, {"quantity": "0"}, + {"quantity": "1.005"}, {"client_id": "x" * 37}, {"client_id": "café"}, + {"valid_until": 1}, {"valid_until": "1780000060000"}): + args = {"price": "0.55", "quantity": "100", **bad} + with pytest.raises(InvalidOrder): + quote_params(RFQ, args.pop("price"), args.pop("quantity"), now_ms=1780000000000, **args) + + def test_a_closed_window_is_refused_locally(self): + async def go(): + stream = GeminiRfqStream(CREDS, clock=lambda: 1780000001.0) + socket = connected(stream) + stream.handle(WS["rfq_open"]) # its window closes at 1780000001000 + with pytest.raises(InvalidOrder, match="no longer quotable"): + await stream.submit_quote(RFQ, "0.55", "100000") + return socket.sent + + assert run(go()) == [] + + def test_geminis_error_comes_back_typed_with_its_code(self): + async def go(): + stream = GeminiRfqStream(CREDS) + connected(stream, rfq_confirm_quote=WS["closed_auction_error"]) + await stream.confirm_quote(RFQ, QUOTE, True) + + with pytest.raises(OrderRejected) as caught: + run(go()) + assert caught.value.reason == "-2010" and "not in a valid state" in str(caught.value) + + @pytest.mark.parametrize("code, kind", [ + (-1002, AuthenticationError), (-1004, PermissionDenied), (-1013, BadRequest), (-1003, RateLimitExceeded), + (-1101, ExchangeNotAvailable), + ]) + def test_the_documented_error_codes(self, code, kind): + error = ws_error({"id": 1, "status": 400, "error": {"code": code, "msg": "no"}}, method="rfq.submit_quote") + assert isinstance(error, kind) + + def test_quote_methods_need_a_key_and_a_live_connection(self): + with pytest.raises(CredentialsMissing): + run(GeminiRfqStream().submit_quote(RFQ, "0.55", "100")) + with pytest.raises(NetworkError, match="never queued"): + run(GeminiRfqStream(CREDS).submit_quote(RFQ, "0.55", "100")) + + def test_no_answer_is_an_unknown_outcome_and_is_not_resent(self): + async def go(): + stream = GeminiRfqStream(CREDS, reply_timeout=0.05) + socket = connected(stream) + with pytest.raises(RequestTimeout, match="outcome unknown"): + await stream.submit_quote(RFQ, "0.55", "100") + return stream, socket.sent + + stream, sent = run(go()) + assert len(sent) == 1 and stream._pending == {} + + def test_a_dropped_connection_fails_the_waiting_method(self): + async def go(): + stream = GeminiRfqStream(CREDS) + connected(stream) + task = asyncio.ensure_future(stream.confirm_quote(RFQ, QUOTE, True)) + await asyncio.sleep(0) + stream.on_disconnect() + with pytest.raises(NetworkError, match="connection lost"): + await task + + run(go()) diff --git a/tests/test_gemini_trading.py b/tests/test_gemini_trading.py index 8ec129c..a7e1338 100644 --- a/tests/test_gemini_trading.py +++ b/tests/test_gemini_trading.py @@ -382,7 +382,7 @@ def test_a_rate_limited_write_is_not_resent(self): def test_the_capabilities_say_what_the_venue_holds(self): assert GeminiTrading.has["create_order"] is True and GeminiTrading.has["edit_order"] is False - assert GeminiTrading.has["fetch_my_trades"] == "partial" and GeminiTrading.has["rfq"] is False + assert GeminiTrading.has["fetch_my_trades"] == "partial" and GeminiTrading.has["rfq"] == "partial" class TestDoctor: From 3cde970b94d4b2e9f96c59afd9c47ce9d87fe4da Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 16/19] feat: novig-parlay-rfq Co-Authored-By: Claude Opus 5.5 --- docs/capabilities.md | 14 +- docs/streaming.md | 2 + docs/trading.md | 111 ++- synpath/trading/credentials.py | 73 ++ synpath/trading/novig.py | 2 +- synpath/trading/novig_rfq.py | 1219 ++++++++++++++++++++++++++ synpath/trading/novig_rfq_signing.py | 187 ++++ synpath/ws/novig_rfq.py | 251 ++++++ tests/samples/blake3_vectors.json | 67 ++ tests/samples/novig_rfq_docs.json | 248 ++++++ tests/test_novig_rfq.py | 887 +++++++++++++++++++ 11 files changed, 3055 insertions(+), 6 deletions(-) create mode 100644 synpath/trading/novig_rfq.py create mode 100644 synpath/trading/novig_rfq_signing.py create mode 100644 synpath/ws/novig_rfq.py create mode 100644 tests/samples/blake3_vectors.json create mode 100644 tests/samples/novig_rfq_docs.json create mode 100644 tests/test_novig_rfq.py diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..ca4105e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -216,8 +216,16 @@ Novig's identity check, from a US location outside Arizona, Michigan and Nevada, with a recent device geolocation from the Novig app; a refused location is a `PermissionDenied` naming the check. Paper (`api.paper.novig.com`, `NOVIG_ENV=paper`) is the same API with play money and no location check. -Ids and keys differ between the two. Novig's parlay RFQ, for registered -liquidity providers, is not supported yet. +Ids and keys differ between the two. + +Novig's parlay RFQ is supported from the liquidity provider's side: +`NovigPricer` answers rounds over the maker WebSocket or signed webhooks +(`/quote`, `/confirm`, `/ping`), handles the 1-second last look, and tracks +the 60% confirm rate; `NovigRFQ` reads the RFQs won and the collateral in +open parlays. It needs an LP account and Novig's OAuth market-maker token +(`NOVIG_RFQ_CLIENT_ID`, `NOVIG_RFQ_CLIENT_SECRET`), not a NOVIG-V3 key. +Asking for a parlay quote, the user's side, is not offered. See +[Novig parlay RFQ](trading.md#novig-parlay-rfq). **What you may do with it.** Smarkets' API terms allow viewing prices and betting on your own account; odds scraping is prohibited. Which countries @@ -539,7 +547,7 @@ onboarded firms. | `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | | `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | | `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `rfq` | yes | no | no | no | no | no | no | no | no | no on `NovigTrading`; liquidity providers price parlay RFQs with `NovigPricer` | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | | `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..bba2e3b 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -46,6 +46,8 @@ asyncio.run(main()) | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | | `NovigMarketStream` | `api.novig.com/v3/ws` (or Paper's); without a key, the public REST book polled | a subaccount's trading key for the WebSocket (the handshake is signed); none to poll | books kept per order, top of book, the tape, market transitions (going in play voids resting orders) | | `NovigUserStream` | `api.novig.com/v3/ws` | a subaccount's trading key | orders (open, fill, cancel, reject) and positions of that subaccount | +| `NovigPricerStream` (`synpath.ws.novig_rfq`) | `api.novig.com/rfq/ws` (or QA's) | Novig's OAuth market-maker token and a registered pricer | parlay RFQ rounds, answered by a `NovigPricer`; the pricer's quotes, last looks and fills, as `VenueEvent`s ([Novig parlay RFQ](trading.md#novig-parlay-rfq)) | +| `NovigExecutionFeed` (`synpath.ws.novig_rfq`) | `api.novig.com/rfq/executions/feed` | Novig's OAuth market-maker token | every RFQ execution on Novig, naming no pricer; a jump in `offset` is a gap | | `MatchbookMarketStream` | `api.matchbook.com` REST, polled (Matchbook has no push API) | none | runner books, top of book and runner status at every poll (5 s by default) | | `ProphetXMarketStream` | the host `GET /websocket/connection-config` names (ProphetX's Talaria, Pusher protocol 7) | the Trading API key pair (every channel is private) | each line's ten best levels a side on every change, top of book, the tournament's tape of fills | | `ProphetXUserStream` | the same host | the Trading API key pair | orders on every change; a fill with each match | diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..4fef0eb 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -131,8 +131,8 @@ play. Production needs Novig's identity check and a US location outside Arizona, Michigan and Nevada, with a recent device geolocation from the Novig app (a VPN is refused); Paper is the same API with play money and no location check. `NovigTrading.echo()` proves the host, key, clock and signature in one -call. Novig's parlay RFQ, for registered liquidity providers, is not -supported yet. See [Novig](capabilities.md#novig). +call. Liquidity providers can price Novig's parlay RFQs with `NovigPricer` +([Novig parlay RFQ](#novig-parlay-rfq)). See [Novig](capabilities.md#novig). ```python from decimal import Decimal @@ -882,3 +882,110 @@ schedule with one public request. Combo RFQ (Gemini's sealed WebSocket auction for multi-leg combos, in beta and run by market makers) is not offered, and nor are WebSocket order placement and `cancelOnDisconnect`. + +## Novig parlay RFQ + +Novig sends every parlay a user builds (2 to 20 legs, as outcome ids) to +each registered liquidity provider's pricer as an RFQ. The auction lasts 3 +seconds; the lowest `price` wins (a tie goes to the larger `max_wager`), +and the winner gets a 1-second last look at the user's actual stake to +confirm or reject. `synpath.trading.novig_rfq.NovigPricer` is that pricer: +you supply the pricing callback, and it answers over the maker WebSocket, +over webhooks, or both. Registering is self-serve but needs an LP account +(developers@novig.com), and the pricer is separate from order entry: the +RFQ routes take Novig's OAuth market-maker bearer token, not a NOVIG-V3 +key. + +```bash +NOVIG_RFQ_CLIENT_ID=... # the OAuth client credentials Novig issues to LPs +NOVIG_RFQ_CLIENT_SECRET=... # (or NOVIG_RFQ_ACCESS_TOKEN, a token used as it is) +NOVIG_RFQ_SHARED_SECRET=... # webhooks only: 64 hex characters, returned once by POST /rfq/pricer +NOVIG_RFQ_ENV=qa # production (the default) or qa; registrations are separate +``` + +```python +from decimal import Decimal + +from synpath.trading.credentials import load_novig_rfq +from synpath.trading.novig_rfq import NovigPricer, ParlayQuote + +async def price(rfq): # a ParlayRFQ: rfq_id, outcome_ids, min_wager, deadline() + if rfq.legs > 6: + return None # decline + return ParlayQuote(price=Decimal("0.18"), max_wager=Decimal("500"), quote_id="mm-1") + +pricer = NovigPricer(price, credentials=load_novig_rfq()) + +async with pricer.stream() as stream: # the maker WebSocket + async for event in stream: # every frame as a VenueEvent, and the stream's status + print(event) + +app = pricer.webhook_app(prefix="/novig") # or webhooks: POST /novig/quote, /confirm, /ping +``` + +**A round.** `price` gets each round as a `ParlayRFQ` and returns a +`ParlayQuote` or `None`, and may be a coroutine. `price` is the taker's +stake per $1 of payout (0.18 means the taker stakes $0.18 to win $1.00, so +the pricer puts up $0.82 a dollar); it is rounded up onto Novig's 0.001 +grid, and a quote Novig would drop (a price outside 0.001 to 0.999, a +`max_wager` of zero, a `quote_id` over 256 bytes) is refused before it is +sent. The callback has until the auction closes, less `margin` (0.25 s); a +slower answer, an exception, or a `max_wager` below the round's minimum +stake declines. On the socket a decline is sent as `decline`, on a webhook +as `204`. A round reaching both transports is priced once and Novig keeps +the first quote. + +**The last look.** `confirm` gets each win as a `LastLook` (the stake, +the price, and the pricer's own round and quote) and returns whether to +take it, within `confirm_timeout` (0.75 s) or the trade is rejected. +Without one, the pricer confirms a trade that matches the quote it sent: its +price, no more than its `max_wager`, its `quote_id`. + +**The confirm rate.** Novig locks a pricer out of quoting for 5 minutes +when it confirms fewer than 60% of its last looks over 30 minutes, once 20 +count. `pricer.confirm_rate` keeps the same count (`standing()`, +`would_lock(False)`, `rejects_left()`), records a lock from the socket's +`FORBIDDEN` error, and the pricer stops quoting while one is known. +`await pricer.sync_standing()` takes Novig's own count from +`GET /rfq/pricer`, which also sees the last looks this process did not. + +**Webhooks.** `webhook_app` checks `X-Novig-Signature` (a BLAKE3 keyed hash +of the raw body under the shared secret; the `blake3` package is used when +installed, a pure-Python version otherwise) on every request and answers a +bad one `401` without reading it. Serve it over public HTTPS at the base URL +you register. `handle_webhook(route, raw_body, signature)` is the same logic +for another web framework. + +**Executions and collateral.** `NovigRFQ` reads the RFQs won +(`fetch_executions`, newest first, paged with `after`; `iter_executions`), +catches up after a gap (`reconcile_executions(known_ids)`, paging from the +newest until a held id), and the collateral held in open parlays +(`fetch_collateral`). `NovigExecutionFeed` is the public tape of every RFQ +execution, marking a jump in `offset` as a gap. The maker socket is never +replayed, so a reconnect is marked `reconcile_required`. + +```python +from synpath.trading.novig_rfq import NovigRFQ, settle + +async with NovigRFQ(load_novig_rfq()) as rfq: + print(await rfq.fetch_pricer()) # registration, confirm standing, any lock + for row in await rfq.fetch_executions(status="open"): + print(row.rfq_id, row.wager, row.price, row.liability) + print(await rfq.fetch_collateral()) + +settle("42", "0.42", [1, "0.5", 1]) # a voided leg at 0.5: the taker gets $50 of the $100 pot +``` + +**Settlement.** A parlay settles as one position: any losing leg settles it +at once as a loss, a voided leg counts at its fair market value, and the +taker gets the pot times the product of the leg values (rounded to three +places, at least 0.001). `settle` and `settlement_scalar` compute it; +`RFQExecution.pricer_payout` reads it off a settled execution. +`eligibility_problems` checks legs against Novig's published combination +rules (2 to 20 legs, no same-kind lines on one game, no NO side of a +yes-or-no market, ...), from leg details you supply; Novig changes the +blocked pairs over time, so its own answer is the RFQ arriving or not. + +`register_pricer(webhook_url=None)` and `ping_webhook()` are there for the +one-time setup; the shared secret is returned only by the first +registration with a webhook URL, so store it then. diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..ae0d017 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -54,6 +54,12 @@ key, PEM, Ed25519 or P-256), NOVIG_ENV (production, the default, or paper), and optionally NOVIG_MANAGEMENT_KEY_ID / NOVIG_MANAGEMENT_PRIVATE_KEY_PATH (subaccounts and funding) + Novig parlay RFQ NOVIG_RFQ_CLIENT_ID, NOVIG_RFQ_CLIENT_SECRET (the liquidity + provider's OAuth client credentials), or NOVIG_RFQ_ACCESS_TOKEN; + NOVIG_RFQ_SHARED_SECRET (webhooks only; NOVIG_RFQ_SECRET is + read too), NOVIG_RFQ_ENV (production, the default, or qa), + NOVIG_RFQ_TOKEN_URL; read by `load_novig_rfq`, not + `load_credentials` Matchbook MATCHBOOK_USERNAME, MATCHBOOK_PASSWORD: betting logs in; market data needs no login ProphetX PROPHETX_ACCESS_KEY, PROPHETX_SECRET_KEY (the Trading API: @@ -445,6 +451,39 @@ def secrets(self) -> list[str]: return [pem.decode(errors="ignore") for pem in (self.private_key_pem, self.management_private_key_pem) if pem] +@dataclass(frozen=True, repr=False) +class NovigRFQCredentials: + """Novig's parlay RFQ, for a liquidity provider's pricer: separate from + the order-entry keys (`NovigCredentials`). + + The RFQ routes (`/rfq/pricer`, `/rfq/executions`, `/rfq/collateral`) and + both RFQ WebSockets take the OAuth 2.0 client-credentials bearer token + Novig issues to market makers, not a NOVIG-V3 signature. The client id + and secret are exchanged for a token (30 minutes) as needed; a token + obtained elsewhere can be given instead. The shared secret, returned + once by `POST /rfq/pricer` with a `webhookUrl`, checks the signature on + every webhook; a WebSocket-only pricer has none.""" + + client_id: str | None = None + client_secret: str | None = field(default=None, repr=False) + access_token: str | None = field(default=None, repr=False) + """A bearer token used as it is, instead of the client credentials.""" + shared_secret: str | None = field(default=None, repr=False) + """64 hex characters: the BLAKE3 key the webhooks are signed with.""" + env: Literal["production", "qa"] = "production" + """`production` at api.novig.com; `qa` at api-qa.novig.us. Registrations + are separate in each.""" + token_url: str | None = None + """Where the client credentials are exchanged; the environment's + `/nbx/v1/auth/emm-token` by default.""" + _public = ("client_id", "env", "token_url") + __repr__ = _redacted_repr + + @property + def secrets(self) -> list[str]: + return [s for s in (self.client_secret, self.access_token, self.shared_secret) if s] + + @dataclass(frozen=True, repr=False) class MatchbookCredentials: """Matchbook: the account's login. Betting and the account's reads (offers, @@ -944,6 +983,40 @@ def load_novig(env: Mapping[str, str]) -> NovigCredentials | None: ) +NOVIG_RFQ_ENVS = ("production", "qa") + + +def load_novig_rfq( + env: Mapping[str, str] | None = None, *, dotenv: Path | str | None = None, redact_logs: bool = True, +) -> NovigRFQCredentials | None: + """A Novig pricer's credentials from the environment (and `.env`), or + `None` when none are set. Not part of `load_credentials`: the pricer is + a liquidity provider's service, not an order-entry adapter.""" + merged = merged_env(env, dotenv=dotenv) + client_id = (merged.get("NOVIG_RFQ_CLIENT_ID") or "").strip() or None + client_secret = (merged.get("NOVIG_RFQ_CLIENT_SECRET") or "").strip() or None + token = (merged.get("NOVIG_RFQ_ACCESS_TOKEN") or "").strip() or None + shared = (merged.get("NOVIG_RFQ_SHARED_SECRET") or merged.get("NOVIG_RFQ_SECRET") or "").strip() or None + if not (client_id or client_secret or token or shared): + return None + if bool(client_id) != bool(client_secret): + raise CredentialsMissing("Novig's RFQ client credentials are a pair: set both NOVIG_RFQ_CLIENT_ID and " + "NOVIG_RFQ_CLIENT_SECRET (issued by Novig to liquidity providers)") + if shared is not None and not re.match(r"^[0-9a-fA-F]{64}$", shared): + raise CredentialsMissing(f"NOVIG_RFQ_SHARED_SECRET must be the 64 hex characters POST /rfq/pricer returned; " + f"got {_shape_of(shared)}") + stage = (merged.get("NOVIG_RFQ_ENV") or "production").strip().lower() + if stage not in NOVIG_RFQ_ENVS: + raise CredentialsMissing(f"NOVIG_RFQ_ENV must be production or qa, got {stage!r}") + creds = NovigRFQCredentials( + client_id=client_id, client_secret=client_secret, access_token=token, shared_secret=shared.lower() if shared else None, + env=stage, token_url=(merged.get("NOVIG_RFQ_TOKEN_URL") or "").strip() or None, # type: ignore[arg-type] + ) + if redact_logs: + SecretFilter.install(creds.secrets) + return creds + + def load_matchbook(env: Mapping[str, str]) -> MatchbookCredentials | None: username, password = env.get("MATCHBOOK_USERNAME"), env.get("MATCHBOOK_PASSWORD") if not username and not password: diff --git a/synpath/trading/novig.py b/synpath/trading/novig.py index aa98561..7809315 100644 --- a/synpath/trading/novig.py +++ b/synpath/trading/novig.py @@ -384,7 +384,7 @@ class NovigTrading(TradingExchange): "fetch_settlements": False, "fetch_queue_position": False, "fetch_fee_estimate": True, - # Novig's parlay RFQ is for registered liquidity providers; not here. + # Novig's parlay RFQ is priced by liquidity providers: `synpath.trading.novig_rfq.NovigPricer`. "rfq": False, "split_merge": False, "watch_orders": False, diff --git a/synpath/trading/novig_rfq.py b/synpath/trading/novig_rfq.py new file mode 100644 index 0000000..c2af389 --- /dev/null +++ b/synpath/trading/novig_rfq.py @@ -0,0 +1,1219 @@ +"""Novig's parlay RFQ, from the liquidity provider's side: a pricer. + +Built to Novig's RFQ documentation (docs.novig.com, API reference, RFQ). + +**A round.** A user on Novig builds a parlay of 2 to 20 legs (outcome ids) +and Novig sends an RFQ to every registered pricer. The auction stays open 3 +seconds; a pricer answers with a `price` (the taker's stake per $1 of +payout, so the parlay's probability as the pricer sees it) and a +`max_wager` (the largest stake it takes), or declines. The lowest price +wins, a tie going to the larger `max_wager`. When the user accepts, the +winner gets a 1-second last look at the user's actual stake and confirms or +rejects; silence rejects. The executed parlay settles as one position. + +**Two transports, one pricer.** `NovigPricer` holds the pricing callback, +the last-look policy and the confirm-rate tracker, and answers rounds from +either transport: + +* the maker WebSocket (`wss://api.novig.com/rfq/ws`), one outbound + connection: `NovigPricer.stream()` (see `synpath.ws.novig_rfq`); +* webhooks Novig calls (`POST {base}/quote`, `/confirm`, `/ping`), each + signed with `X-Novig-Signature`: `NovigPricer.webhook_app()` is a FastAPI + app serving the three, and `handle_webhook` the same logic for any other + framework. + +A round that reaches both is priced once: the second transport gets the +same answer, and Novig keeps the first quote it receives. + +**Confirm rate.** A pricer must confirm at least 60% of its last looks over +the past 30 minutes, judged once 20 count; below that Novig locks it out of +quoting for 5 minutes and starts counting again from zero. `ConfirmRate` +keeps the same count locally, so a pricer can see a lock coming, and the +pricer stops quoting while one is known. + +**Auth.** The RFQ routes and sockets take the OAuth 2.0 client-credentials +bearer token Novig issues to market makers (`NovigRFQCredentials`), not the +NOVIG-V3 order-entry signature. The webhook signature is a BLAKE3 keyed +hash with the pricer's shared secret (`novig_rfq_signing`). + +**Reads.** `NovigRFQ` reads the registration and confirm standing +(`GET /rfq/pricer`), the RFQs won (`GET /rfq/executions`, paged, and +`reconcile_executions` to catch up after a gap) and the collateral held in +open parlays (`GET /rfq/collateral`). Registration (`register_pricer`) and +the webhook test (`ping_webhook`) are there for the operator to run once. + +Amounts are Novig Cash in `Decimal`; Novig sends and takes them as strings. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +import math +import re +import time +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from decimal import ROUND_CEILING, ROUND_DOWN, ROUND_HALF_UP, Decimal +from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, Sequence + +import httpx + +from ..base import AsyncHttpClient +from ..errors import AuthenticationError, MarketNotFound +from . import novig_rfq_signing as signing +from .credentials import NovigRFQCredentials +from .errors import CredentialsMissing, InvalidOrder, PermissionDenied + +log = logging.getLogger("synpath.trading.novig_rfq") + +VENUE = "novig" + +API_URLS = {"production": "https://api.novig.com", "qa": "https://api-qa.novig.us"} +WS_URLS = {"production": "wss://api.novig.com/rfq/ws", "qa": "wss://api-qa.novig.us/rfq/ws"} +FEED_URLS = {"production": "wss://api.novig.com/rfq/executions/feed", "qa": "wss://api-qa.novig.us/rfq/executions/feed"} +TOKEN_PATH = "/nbx/v1/auth/emm-token" +"""Where the client credentials are exchanged for a bearer token.""" + +TOKEN_LIFETIME = 30 * 60.0 +"""Seconds a token lasts when the answer does not say.""" +TOKEN_REFRESH_EARLY = 120.0 + +AUCTION_SECONDS = 3.0 +LAST_LOOK_SECONDS = 1.0 +QUOTE_LIFETIME_PREGAME = 30.0 +QUOTE_LIFETIME_LIVE = 10.0 +"""Seconds a winning quote stays acceptable after the auction closes.""" + +MIN_WAGER = Decimal("10.00") +"""The platform minimum: a quote whose `max_wager` is below it, or below the +taker's `min_wager`, is passed over.""" +MIN_LEGS, MAX_LEGS = 2, 20 +PRICE_STEP = Decimal("0.001") +PRICE_MIN, PRICE_MAX = Decimal("0.001"), Decimal("0.999") +QUOTE_ID_MAX_BYTES = 256 + +CONFIRM_THRESHOLD = Decimal("0.60") +CONFIRM_LOOKBACK = 30 * 60.0 +CONFIRM_MIN_SAMPLE = 20 +LOCK_SECONDS = 5 * 60.0 + +EXECUTIONS_PAGE = 500 +CENT = Decimal("0.01") +ZERO, ONE = Decimal("0"), Decimal("1") + +Transport = Literal["websocket", "webhook"] + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return ZERO + return Decimal(str(value)) + + +def maybe_D(value: Any) -> Decimal | None: + return None if value in (None, "") else Decimal(str(value)) + + +_FRACTION = re.compile(r"\.(\d+)") + + +def ms_of(value: Any) -> int | None: + """An RFC 3339 time as Unix milliseconds; `None` when absent or unreadable.""" + if value in (None, ""): + return None + if isinstance(value, (int, float)): + return int(value) + text = str(value).strip().replace("Z", "+00:00").replace("z", "+00:00") + # Python 3.10 reads three or six fractional digits only. + text = _FRACTION.sub(lambda m: "." + (m.group(1) + "000000")[:6], text, count=1) + try: + moment = datetime.fromisoformat(text) + except ValueError: + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return int(moment.timestamp() * 1000) + + +def now_ms() -> int: + return int(time.time() * 1000) + + +# --------------------------------------------------------------------------- +# Rounds, quotes and last looks +# --------------------------------------------------------------------------- + +@dataclass(frozen=True, slots=True, kw_only=True) +class ParlayRFQ: + """One round: the legs a taker wants combined, and when it closes.""" + + rfq_id: str + outcome_ids: tuple[str, ...] + """One outcome id per leg, in the order the taker asked for them.""" + min_wager: Decimal = MIN_WAGER + """The smallest stake a quote must cover: the taker's minimum or the + platform's $10, whichever is higher.""" + expires_at: int | None = None + """When the auction closes, Unix ms (the venue's clock).""" + created_at: int | None = None + received_at: int = field(default_factory=now_ms) + transport: Transport = "websocket" + info: dict[str, Any] = field(default_factory=dict) + + @property + def legs(self) -> int: + return len(self.outcome_ids) + + def deadline(self) -> int: + """When an answer must be in, Unix ms on this machine's clock: the + auction's close, or 3 s after this round arrived when that is sooner.""" + local = self.received_at + int(AUCTION_SECONDS * 1000) + return min(local, self.expires_at) if self.expires_at else local + + @classmethod + def from_ws(cls, data: dict[str, Any], *, received_at: int | None = None) -> "ParlayRFQ": + """From the socket's `rfq_created`.""" + return cls( + rfq_id=str(data["rfq_id"]), outcome_ids=tuple(str(o) for o in data.get("outcome_ids") or ()), + min_wager=max(MIN_WAGER, D(data.get("min_wager"))), expires_at=ms_of(data.get("expires_at")), + received_at=received_at or now_ms(), transport="websocket", info=dict(data), + ) + + @classmethod + def from_webhook(cls, body: dict[str, Any], *, received_at: int | None = None) -> "ParlayRFQ": + """From a `/quote` request. `min_wager` is left out when the taker set + none; the platform minimum still applies. The auction closes 3 s + after `timestamp`.""" + created = ms_of(body.get("timestamp")) + return cls( + rfq_id=str(body["rfq_id"]), outcome_ids=tuple(str(o) for o in body.get("outcome_ids") or ()), + min_wager=max(MIN_WAGER, D(body.get("min_wager"))), created_at=created, + expires_at=created + int(AUCTION_SECONDS * 1000) if created else None, + received_at=received_at or now_ms(), transport="webhook", info=dict(body), + ) + + +def round_price(price: Any) -> Decimal: + """A price on Novig's RFQ grid of 0.001, rounded up (toward the pricer), + so the venue's own rounding to the nearest 0.001 never moves it.""" + value = D(price).quantize(PRICE_STEP, rounding=ROUND_CEILING) + return value + + +@dataclass(frozen=True, slots=True) +class ParlayQuote: + """A pricer's answer to a round. + + `price` is the taker's stake per $1 of payout: `0.45` means the taker + stakes $0.45 to win $1.00 back, so the pricer puts up $0.55. Lower wins. + `max_wager` is the largest taker stake taken at that price. `quote_id` + is the pricer's own reference (up to 256 bytes), handed back on the last + look as `external_flag`.""" + + price: Decimal + max_wager: Decimal + quote_id: str | None = None + + def checked(self) -> "ParlayQuote": + """The quote as it will be sent: the price on the 0.001 grid, and + refused (`InvalidOrder`) where Novig would drop it.""" + price = round_price(self.price) + if not PRICE_MIN <= price <= PRICE_MAX: + raise InvalidOrder(f"{VENUE}: RFQ price {self.price} is outside 0.001 to 0.999 after rounding") + wager = D(self.max_wager) + if wager <= 0: + raise InvalidOrder(f"{VENUE}: RFQ max_wager must be above zero, got {self.max_wager}") + if self.quote_id is not None and len(self.quote_id.encode("utf-8")) > QUOTE_ID_MAX_BYTES: + raise InvalidOrder(f"{VENUE}: RFQ quote_id is longer than {QUOTE_ID_MAX_BYTES} bytes") + return ParlayQuote(price=price, max_wager=wager, quote_id=self.quote_id) + + def ws_data(self, rfq_id: str) -> dict[str, Any]: + """`create_quote`'s data: every amount a decimal string.""" + data: dict[str, Any] = {"rfq_id": rfq_id, "price": str(self.price), "max_wager": str(self.max_wager)} + if self.quote_id is not None: + data["quote_id"] = self.quote_id + return data + + def webhook_body(self) -> dict[str, Any]: + """`/quote`'s answer: `price` a JSON number, `max_wager` a string.""" + body: dict[str, Any] = {"price": float(self.price), "max_wager": str(self.max_wager)} + if self.quote_id is not None: + body["quote_id"] = self.quote_id + return body + + def collateral(self, wager: Decimal | None = None) -> Decimal: + """What the pricer puts up against a stake (the whole `max_wager` + by default): the pot less the stake.""" + return collateral(self.max_wager if wager is None else D(wager), self.price) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class LastLook: + """A won quote the taker accepted: confirm or reject within 1 second.""" + + rfq_id: str + wager: Decimal + """The taker's actual stake; never above the quote's `max_wager`.""" + price: Decimal + """The price selected: the pricer's own.""" + quote_id: str | None = None + """Novig's id for the quote (the socket's `quote_accepted`); what the + socket's `confirm` names. Webhooks carry none.""" + external_flag: str | None = None + """The pricer's own `quote_id`, or `None` when it sent none.""" + transport: Transport = "websocket" + received_at: int = field(default_factory=now_ms) + rfq: ParlayRFQ | None = None + """The round, when this pricer saw it.""" + quote: ParlayQuote | None = None + """What this pricer quoted on the round, when it did.""" + info: dict[str, Any] = field(default_factory=dict) + + def deadline(self) -> int: + return self.received_at + int(LAST_LOOK_SECONDS * 1000) + + @property + def collateral(self) -> Decimal: + return collateral(self.wager, self.price) + + @classmethod + def from_ws(cls, data: dict[str, Any], *, received_at: int | None = None) -> "LastLook": + """From the socket's `quote_accepted`.""" + return cls( + rfq_id=str(data["rfq_id"]), wager=D(data.get("wager")), price=D(data.get("price")), + quote_id=str(data["quote_id"]) if data.get("quote_id") else None, + external_flag=data.get("external_flag"), transport="websocket", + received_at=received_at or now_ms(), info=dict(data), + ) + + @classmethod + def from_webhook(cls, body: dict[str, Any], *, received_at: int | None = None) -> "LastLook": + """From a `/confirm` request.""" + return cls( + rfq_id=str(body["rfq_id"]), wager=D(body.get("wager")), price=D(body.get("price")), + external_flag=body.get("external_flag"), transport="webhook", + received_at=received_at or now_ms(), info=dict(body), + ) + + +def matches_quote(look: LastLook) -> bool: + """The default last-look policy: confirm a trade on a quote this pricer + sent, at its price, for no more than its `max_wager`, under its own + `quote_id`. Anything else is rejected.""" + quote = look.quote + if quote is None: + return False + if look.wager <= 0 or look.wager > quote.max_wager: + return False + if round_price(look.price) != quote.price: + return False + if look.external_flag is not None and quote.quote_id is not None and look.external_flag != quote.quote_id: + return False + return True + + +# --------------------------------------------------------------------------- +# Money: the pot, collateral and settlement +# --------------------------------------------------------------------------- + +def pot(wager: Any, price: Any) -> Decimal: + """The parlay's pot, the stake plus the pricer's collateral: the stake + over the quoted price, rounded down to the cent.""" + return (D(wager) / D(price)).quantize(CENT, rounding=ROUND_DOWN) + + +def collateral(wager: Any, price: Any) -> Decimal: + """What the pricer puts up: the pot less the stake. The same figure as + an execution's `liability`.""" + return pot(wager, price) - D(wager) + + +LegValue = Decimal | float | int | str | None + + +def settlement_scalar(leg_values: Iterable[LegValue]) -> Decimal | None: + """What share of the pot goes to the taker, from each leg's graded value + (`1` a win, `0` a loss, a voided leg's fair market value) or `None` for a + leg not yet graded. + + Any loss settles the parlay at once, at `0`, whatever is still open; + otherwise an open leg leaves it open (`None`); otherwise the product of + the values, rounded to three decimals and never below `0.001`.""" + values = [None if v is None else D(v) for v in leg_values] + if any(v is not None and v == 0 for v in values): + return ZERO + if any(v is None for v in values): + return None + product = ONE + for value in values: + if not ZERO <= value <= ONE: # type: ignore[operator] + raise ValueError(f"a leg's value is between 0 and 1, got {value}") + product *= value # type: ignore[operator] + return max(Decimal("0.001"), product.quantize(PRICE_STEP, rounding=ROUND_HALF_UP)) + + +@dataclass(frozen=True, slots=True) +class ParlaySettlement: + result: Literal["open", "loss", "win", "fmv"] + """`loss`: the pricer takes the pot; `win`: the taker does; `fmv`: they + split it by `scalar` (a voided leg).""" + scalar: Decimal | None + pot: Decimal + taker_payout: Decimal | None + pricer_payout: Decimal | None + pricer_pnl: Decimal | None + """The pricer's payout less its collateral.""" + + +def settle(wager: Any, price: Any, leg_values: Iterable[LegValue]) -> ParlaySettlement: + """How an executed parlay pays, by Novig's rules from August 20, 2026: + the taker gets the pot times the scalar, the pricer the rest. The + taker's share is rounded down to the cent here; Novig does not publish + its rounding of a split pot.""" + whole = pot(wager, price) + scalar = settlement_scalar(leg_values) + if scalar is None: + return ParlaySettlement("open", None, whole, None, None, None) + taker = (whole * scalar).quantize(CENT, rounding=ROUND_DOWN) + pricer = whole - taker + result: Literal["loss", "win", "fmv"] = "loss" if scalar == 0 else "win" if scalar == 1 else "fmv" + return ParlaySettlement(result, scalar, whole, taker, pricer, pricer - (whole - D(wager))) + + +# --------------------------------------------------------------------------- +# Eligibility +# --------------------------------------------------------------------------- + +LINE_KINDS = {"moneyline": "side", "spread": "side", "total": "total"} +"""Lines of the same kind on one game do not combine: a spread with a +moneyline, two spreads, two totals.""" + +POWER_PLAY_CONFLICTS = frozenset({"points", "assists", "goals", "shots_on_goal"}) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ParlayLeg: + """What the eligibility rules look at in one leg. The RFQ itself carries + only outcome ids; a pricer fills these from its own catalog.""" + + outcome_id: str + event_id: str | None = None + """The game (or futures question) the leg is on.""" + league: str | None = None + market_type: str | None = None + """`moneyline`, `spread`, `total`, or a prop's type, like `player_points` + or `first_touchdown_scorer`.""" + period: str = "game" + """`game` or `first_half`: the same line on each half counts apart.""" + team_ids: tuple[str, ...] = () + """The teams the leg's event involves.""" + player_id: str | None = None + stats: frozenset[str] = frozenset() + """A player prop's stats: `{"points", "rebounds", "assists"}` for PRA.""" + future: bool = False + special: bool = False + """A special or promo market.""" + yes_no: bool = False + """A yes-or-no market (a future, a first scorer, a draw, a three-way + moneyline leg).""" + no_side: bool = False + """Whether the leg takes that market's NO.""" + open: bool = True + """Whether the leg's event is open, before the game or in play.""" + + +def eligibility_problems(legs: Sequence[ParlayLeg]) -> list[str]: + """Why Novig would refuse to send an RFQ for these legs, by its published + rules; empty when none apply. The venue's blocked pairs change over time, + so this is a pre-check, not the venue's answer, and a pricer can decline + any RFQ it receives.""" + problems: list[str] = [] + if not MIN_LEGS <= len(legs) <= MAX_LEGS: + problems.append(f"a parlay has {MIN_LEGS} to {MAX_LEGS} legs, not {len(legs)}") + seen: set[str] = set() + for leg in legs: + if leg.outcome_id in seen: + problems.append(f"outcome {leg.outcome_id} appears twice") + seen.add(leg.outcome_id) + if not leg.open: + problems.append(f"leg {leg.outcome_id}: its event is not open") + if leg.special: + problems.append(f"leg {leg.outcome_id}: special and promo markets cannot be legs") + if leg.yes_no and leg.no_side: + problems.append(f"leg {leg.outcome_id}: the NO side of a yes-or-no market cannot be a leg") + for i, a in enumerate(legs): + for b in legs[i + 1:]: + problems.extend(_pair_problems(a, b)) + return problems + + +def is_eligible(legs: Sequence[ParlayLeg]) -> bool: + return not eligibility_problems(legs) + + +def _pair_problems(a: ParlayLeg, b: ParlayLeg) -> list[str]: + pair = f"legs {a.outcome_id} and {b.outcome_id}" + out: list[str] = [] + same_game = a.event_id is not None and a.event_id == b.event_id + if same_game: + kind_a, kind_b = LINE_KINDS.get(a.market_type or ""), LINE_KINDS.get(b.market_type or "") + if kind_a and kind_a == kind_b and a.period == b.period: + out.append(f"{pair}: two {kind_a} lines on the same game") + if (a.market_type and a.market_type == b.market_type and "first" in a.market_type + and "scorer" in a.market_type): + out.append(f"{pair}: two first-scorer legs of the same type") + pp_a, pp_b = "power_play_points" in a.stats, "power_play_points" in b.stats + if (pp_a and b.stats & POWER_PLAY_CONFLICTS) or (pp_b and a.stats & POWER_PLAY_CONFLICTS): + out.append(f"{pair}: power play points with points, assists, goals or shots on goal") + if a.player_id is not None and a.player_id == b.player_id: + if a.market_type and a.market_type == b.market_type: + out.append(f"{pair}: two legs of the same market type on one player") + elif a.stats and b.stats and a.stats != b.stats and (a.stats <= b.stats or b.stats <= a.stats): + out.append(f"{pair}: one stat contains the other on one player") + if not same_game and a.event_id is not None and b.event_id is not None: + if set(a.team_ids) & set(b.team_ids): + out.append(f"{pair}: legs from different games share a team") + if (a.future or b.future) and a.league is not None and a.league == b.league: + out.append(f"{pair}: a future combines only with legs from other leagues") + return out + + +# --------------------------------------------------------------------------- +# Confirm rate +# --------------------------------------------------------------------------- + +@dataclass(frozen=True, slots=True) +class ConfirmStanding: + """Where a pricer stands against the confirm rate (`GET /rfq/pricer`).""" + + lookback_minutes: int = int(CONFIRM_LOOKBACK // 60) + counted: int = 0 + confirmed: int = 0 + threshold: Decimal = CONFIRM_THRESHOLD + min_sample: int = CONFIRM_MIN_SAMPLE + + @property + def rate(self) -> Decimal | None: + return Decimal(self.confirmed) / Decimal(self.counted) if self.counted else None + + @property + def judged(self) -> bool: + return self.counted >= self.min_sample + + @property + def below(self) -> bool: + rate = self.rate + return self.judged and rate is not None and rate < self.threshold + + def rejects_left(self) -> int: + """How many rejections (or misses) in a row this window takes before + the next one locks the pricer out; `0` when the next one would.""" + if self.below: + return 0 + rejects = 0 + while True: + counted = self.counted + rejects + 1 + if counted >= self.min_sample and Decimal(self.confirmed) / Decimal(counted) < self.threshold: + return rejects + rejects += 1 + + @classmethod + def from_row(cls, row: dict[str, Any] | None) -> "ConfirmStanding": + row = row or {} + return cls( + lookback_minutes=int(row.get("lookbackMinutes") or CONFIRM_LOOKBACK // 60), + counted=int(row.get("counted") or 0), confirmed=int(row.get("confirmed") or 0), + threshold=D(row.get("threshold") or CONFIRM_THRESHOLD), min_sample=int(row.get("minSample") or CONFIRM_MIN_SAMPLE), + ) + + +@dataclass(frozen=True, slots=True) +class PricerLock: + until: int | None + """Unix ms, or `None` for a lock with no set end.""" + source: str + """`confirm_rate`, or `admin` for one Novig set by hand.""" + + +class ConfirmRate: + """The confirm rate as Novig counts it, kept locally: every last look in + the past 30 minutes counts, a confirm for, a reject or a miss against. + Once 20 count, a rate below 60% locks the pricer out for 5 minutes, and + counting starts again from zero when the lock ends. + + Novig also counts last looks this process never saw (a confirm that + arrived late, a webhook that errored), so `sync` takes the venue's own + standing from `GET /rfq/pricer` when it is read.""" + + def __init__(self, *, threshold: Decimal = CONFIRM_THRESHOLD, lookback: float = CONFIRM_LOOKBACK, + min_sample: int = CONFIRM_MIN_SAMPLE, lock_seconds: float = LOCK_SECONDS, + clock: Callable[[], float] = time.time): + self.threshold = Decimal(str(threshold)) + self.lookback = lookback + self.min_sample = min_sample + self.lock_seconds = lock_seconds + self.clock = clock + self._looks: dict[str, tuple[float, bool]] = {} + self._base_counted = 0 + self._base_confirmed = 0 + self.lock: PricerLock | None = None + + def _now(self, at: float | None) -> float: + return self.clock() if at is None else at + + def _prune(self, now: float) -> None: + horizon = now - self.lookback + for key in [k for k, (t, _) in self._looks.items() if t < horizon]: + del self._looks[key] + + def record(self, rfq_id: str, confirmed: bool, *, at: float | None = None) -> None: + """One last look answered (`confirmed`), or rejected or missed.""" + now = self._now(at) + if self.locked(now): + return + self._prune(now) + self._looks[rfq_id] = (now, bool(confirmed)) + standing = self.standing(now) + if standing.below: + self.locked_out(until=int((now + self.lock_seconds) * 1000), source="confirm_rate", at=now) + + def amend(self, rfq_id: str, confirmed: bool) -> None: + """Correct an answer Novig counted otherwise: a confirm that reached + it after the window closed counts as a miss.""" + if rfq_id in self._looks: + stamp, _ = self._looks[rfq_id] + self._looks[rfq_id] = (stamp, bool(confirmed)) + + def standing(self, at: float | None = None) -> ConfirmStanding: + now = self._now(at) + self._prune(now) + counted = self._base_counted + len(self._looks) + confirmed = self._base_confirmed + sum(1 for _, ok in self._looks.values() if ok) + return ConfirmStanding(int(self.lookback // 60), counted, confirmed, self.threshold, self.min_sample) + + @property + def rate(self) -> Decimal | None: + return self.standing().rate + + def would_lock(self, confirmed: bool, *, at: float | None = None) -> bool: + """Whether answering the next last look this way would lock the pricer out.""" + standing = self.standing(at) + after = ConfirmStanding(standing.lookback_minutes, standing.counted + 1, + standing.confirmed + (1 if confirmed else 0), standing.threshold, standing.min_sample) + return after.below + + def locked(self, at: float | None = None) -> bool: + lock = self.lock + if lock is None: + return False + if lock.until is None: + return True + if self._now(at) * 1000 >= lock.until: + self.lock = None + return False + return True + + def locked_out(self, *, until: int | None, source: str = "confirm_rate", at: float | None = None) -> None: + """Novig locked the pricer out; counting starts from zero when it ends.""" + self.lock = PricerLock(until=until, source=source) + self._looks.clear() + self._base_counted = self._base_confirmed = 0 + + def sync(self, standing: ConfirmStanding | None, lock: PricerLock | None = None) -> None: + """Take Novig's own count as the base; last looks recorded here from + now on are added to it.""" + if standing is not None: + self._looks.clear() + self._base_counted, self._base_confirmed = standing.counted, standing.confirmed + self.threshold, self.min_sample = standing.threshold, standing.min_sample + self.lock = lock + + +_UNTIL = re.compile(r"until\s+(\d{4}-\d{2}-\d{2}T[0-9:.]+(?:Z|[+-]\d{2}:\d{2})?)") + + +def lock_of_message(message: str) -> PricerLock | None: + """A `FORBIDDEN` error's lock: `"... locked out of RFQ quoting until + 2026-10-06T18:28:00Z for a low confirm rate."`""" + if "locked out" not in (message or "").lower(): + return None + match = _UNTIL.search(message) + source = "confirm_rate" if "confirm rate" in message.lower() else "admin" + return PricerLock(until=ms_of(match.group(1)) if match else None, source=source) + + +# --------------------------------------------------------------------------- +# REST: registration, executions, collateral +# --------------------------------------------------------------------------- + +@dataclass(frozen=True, slots=True) +class PricerRegistration: + pricer_id: str + webhook_url: str | None + shared_secret: str | None = field(default=None, repr=False) + """Only `register_pricer` returns it, once; store it.""" + standing: ConfirmStanding | None = None + lock: PricerLock | None = None + info: dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "PricerRegistration": + lock = row.get("lock") + return cls( + pricer_id=str(row.get("pricerId") or ""), webhook_url=row.get("webhookUrl"), + shared_secret=row.get("sharedSecret"), + standing=ConfirmStanding.from_row(row["confirmStanding"]) if row.get("confirmStanding") else None, + lock=PricerLock(until=ms_of(lock.get("until")), source=str(lock.get("source") or "")) if lock else None, + info={k: v for k, v in row.items() if k != "sharedSecret"}, + ) + + +@dataclass(frozen=True, slots=True) +class RFQExecution: + """An RFQ this pricer won (`GET /rfq/executions`).""" + + rfq_id: str + parlay_id: str | None + wager: Decimal + price: Decimal + liability: Decimal + """The pricer's collateral on the parlay.""" + status: str + """`open` until the parlay settles, then `settled`.""" + result: str | None + """`win`, `loss`, `push` (before August 20, 2026) or `fmv`, once settled: + the taker's result.""" + fmv_value: Decimal | None + outcome_ids: tuple[str, ...] + """The legs, sorted by outcome id (not in the taker's order).""" + executed_at: int | None + settled_at: int | None + info: dict[str, Any] = field(default_factory=dict, repr=False) + + @property + def pot(self) -> Decimal: + return self.wager + self.liability + + @property + def pricer_payout(self) -> Decimal | None: + """What the pricer was paid from the pot, once settled. A `push` + refunded the stake, so the pricer got its collateral back.""" + if self.status != "settled" or self.result is None: + return None + if self.result == "loss": + return self.pot + if self.result == "win": + return ZERO + if self.result == "push": + return self.liability + if self.result == "fmv" and self.fmv_value is not None: + return self.pot - (self.pot * self.fmv_value).quantize(CENT, rounding=ROUND_DOWN) + return None + + @property + def pricer_pnl(self) -> Decimal | None: + paid = self.pricer_payout + return None if paid is None else paid - self.liability + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "RFQExecution": + return cls( + rfq_id=str(row.get("rfq_id") or ""), parlay_id=row.get("parlay_id"), wager=D(row.get("wager")), + price=D(row.get("price")), liability=D(row.get("liability")), status=str(row.get("status") or ""), + result=row.get("result"), fmv_value=maybe_D(row.get("fmv_value")), + outcome_ids=tuple(str(o) for o in row.get("outcome_ids") or ()), + executed_at=ms_of(row.get("executed_at")), settled_at=ms_of(row.get("settled_at")), info=dict(row), + ) + + +@dataclass(frozen=True, slots=True) +class RFQCollateral: + """The collateral in RFQ parlays that filled and have not settled.""" + + collateral: Decimal + rfqs_filled: int + timestamp: int | None + info: dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "RFQCollateral": + return cls(collateral=D(row.get("rfq_collateral")), rfqs_filled=int(row.get("rfqs_filled") or 0), + timestamp=ms_of(row.get("timestamp")), info=dict(row)) + + +class BearerToken: + """The market-maker bearer token: a fixed one, or one exchanged for the + client credentials and renewed before it expires.""" + + def __init__(self, credentials: NovigRFQCredentials, *, base_url: str, client: httpx.AsyncClient | None = None, + clock: Callable[[], float] = time.time): + self.credentials = credentials + self.url = credentials.token_url or f"{base_url.rstrip('/')}{TOKEN_PATH}" + self._client = client + self._token: str | None = credentials.access_token + self._expires = math.inf if credentials.access_token else 0.0 + self._lock = asyncio.Lock() + self.clock = clock + + @property + def fixed(self) -> bool: + return bool(self.credentials.access_token) + + def invalidate(self) -> None: + if not self.fixed: + self._token, self._expires = None, 0.0 + + async def get(self) -> str: + if self._token and self.clock() < self._expires - TOKEN_REFRESH_EARLY: + return self._token + async with self._lock: + if self._token and self.clock() < self._expires - TOKEN_REFRESH_EARLY: + return self._token + creds = self.credentials + if not (creds.client_id and creds.client_secret): + raise CredentialsMissing( + f"{VENUE}: the RFQ routes need a market-maker bearer token; set NOVIG_RFQ_CLIENT_ID and " + "NOVIG_RFQ_CLIENT_SECRET (or NOVIG_RFQ_ACCESS_TOKEN)" + ) + client = self._client or httpx.AsyncClient(timeout=30.0) + try: + response = await client.post(self.url, json={ + "grant_type": "client_credentials", "client_id": creds.client_id, + "client_secret": creds.client_secret, + }) + finally: + if self._client is None: + await client.aclose() + if response.status_code >= 400: + raise AuthenticationError(f"{VENUE}: the token endpoint refused the client credentials " + f"({response.status_code})", status=response.status_code) + body = response.json() + token = body.get("access_token") if isinstance(body, dict) else None + if not token: + raise AuthenticationError(f"{VENUE}: the token endpoint answered without an access_token") + lifetime = float(body.get("expires_in") or TOKEN_LIFETIME) + self._token, self._expires = str(token), self.clock() + lifetime + return self._token + + +class BearerAuth(httpx.Auth): + """Puts the bearer token on each request, and on a 401 renews it and + tries once more.""" + + def __init__(self, token: BearerToken): + self.token = token + + async def async_auth_flow(self, request: httpx.Request) -> AsyncIterator[httpx.Request]: # type: ignore[override] + request.headers["Authorization"] = f"Bearer {await self.token.get()}" + response = yield request + if response.status_code == 401 and not self.token.fixed: + self.token.invalidate() + request.headers["Authorization"] = f"Bearer {await self.token.get()}" + yield request + + +class NovigRFQ: + """Novig's RFQ REST routes for a pricer's account. + + ```python + from synpath.trading.credentials import load_novig_rfq + from synpath.trading.novig_rfq import NovigRFQ + + async with NovigRFQ(load_novig_rfq()) as rfq: + print(await rfq.fetch_pricer(), await rfq.fetch_collateral()) + ``` + """ + + def __init__(self, credentials: NovigRFQCredentials | None = None, *, env: str | None = None, + url: str | None = None, client: httpx.AsyncClient | None = None, + token_client: httpx.AsyncClient | None = None, timeout: float = 30.0): + credentials = credentials or NovigRFQCredentials() + self.credentials = credentials + self.env = env or credentials.env + if self.env not in API_URLS: + raise ValueError(f"{VENUE}: the RFQ env is production or qa, got {self.env!r}") + self.url = (url or API_URLS[self.env]).rstrip("/") + self.token = BearerToken(credentials, base_url=self.url, client=token_client) + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True, + headers={"Accept": "application/json"}) + shared.auth = BearerAuth(self.token) + self._own_client = client is None + self.http = AsyncHttpClient(self.url, limiter=None, client=shared, venue=VENUE) + + async def __aenter__(self) -> "NovigRFQ": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.close() + + async def close(self) -> None: + if self._own_client: + await self.http._client.aclose() + + async def _call(self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None) -> Any: + query = {k: v for k, v in (params or {}).items() if v is not None} + try: + return await self.http.request(method, path, params=query or None, json=body) + except AuthenticationError as exc: + if exc.status == 403: + raise PermissionDenied( + f"{VENUE}: {path} refused (403): the account needs a registered pricer and an LP account " + "(developers@novig.com)", body=exc.body, status=exc.status) from None + raise + + # -- registration --------------------------------------------------------- + + async def fetch_pricer(self) -> PricerRegistration | None: + """The registration and confirm standing, without the secret; `None` + with no pricer registered.""" + try: + row = await self._call("GET", "/rfq/pricer") + except MarketNotFound: + return None + return PricerRegistration.from_row(row or {}) + + async def register_pricer(self, webhook_url: str | None = None) -> PricerRegistration: + """Register the account's pricer, or change its webhook URL. The + first call with a `webhook_url` returns the shared secret, once; a + first call without one registers a WebSocket-only pricer. A webhook + cannot be removed once registered.""" + body: dict[str, Any] = {} if webhook_url is None else {"webhookUrl": webhook_url} + return PricerRegistration.from_row(await self._call("POST", "/rfq/pricer", body=body) or {}) + + async def ping_webhook(self) -> dict[str, Any]: + """Have Novig send a signed request to `{webhook_url}/ping` and report + how it answered (`url`, `ok`, `status`, `error`). A successful ping + also restarts a paused webhook.""" + return dict(await self._call("POST", "/rfq/pricer/ping") or {}) + + # -- executions ----------------------------------------------------------- + + async def fetch_executions(self, *, status: Literal["open", "settled"] | None = None, + limit: int = EXECUTIONS_PAGE, after: str | None = None) -> list[RFQExecution]: + """One page of the RFQs this pricer won, newest first. `after` is the + last `rfq_id` of the previous page.""" + if not 1 <= limit <= EXECUTIONS_PAGE: + raise ValueError(f"{VENUE}: limit is 1 to {EXECUTIONS_PAGE}") + rows = await self._call("GET", "/rfq/executions", params={"status": status, "limit": limit, "after": after}) + return [RFQExecution.from_row(r) for r in rows or []] + + async def iter_executions(self, *, status: Literal["open", "settled"] | None = None, + page: int = EXECUTIONS_PAGE) -> AsyncIterator[RFQExecution]: + """Every execution, newest first, page by page.""" + after: str | None = None + while True: + rows = await self.fetch_executions(status=status, limit=page, after=after) + for row in rows: + yield row + if len(rows) < page: + return + after = rows[-1].rfq_id + + async def reconcile_executions(self, known: Iterable[str], *, page: int = 100, + max_pages: int = 50) -> list[RFQExecution]: + """The executions missing from `known` (rfq ids already held), newest + first: pages from the newest until one already held is reached, as + Novig's documentation recommends after a gap in the feed.""" + held = set(known) + missing: list[RFQExecution] = [] + after: str | None = None + for _ in range(max_pages): + rows = await self.fetch_executions(limit=page, after=after) + for row in rows: + if row.rfq_id in held: + return missing + missing.append(row) + if len(rows) < page: + return missing + after = rows[-1].rfq_id + return missing + + async def fetch_collateral(self) -> RFQCollateral: + return RFQCollateral.from_row(await self._call("GET", "/rfq/collateral") or {}) + + +# --------------------------------------------------------------------------- +# The pricer +# --------------------------------------------------------------------------- + +PriceFn = Callable[[ParlayRFQ], "ParlayQuote | None | Awaitable[ParlayQuote | None]"] +ConfirmFn = Callable[[LastLook], "bool | Awaitable[bool]"] + + +@dataclass +class _Round: + rfq: ParlayRFQ + answer: "asyncio.Future[ParlayQuote | None]" + at: float + + +@dataclass +class PricerStats: + rounds: int = 0 + quoted: int = 0 + declined: int = 0 + late: int = 0 + """Rounds the pricing callback did not answer in time.""" + errors: int = 0 + last_looks: int = 0 + confirmed: int = 0 + rejected: int = 0 + + +async def _maybe(value: Any) -> Any: + return await value if inspect.isawaitable(value) else value + + +class NovigPricer: + """Prices Novig parlay RFQs over the WebSocket, webhooks, or both. + + ```python + from decimal import Decimal + from synpath.trading.credentials import load_novig_rfq + from synpath.trading.novig_rfq import NovigPricer, ParlayQuote + + async def price(rfq): + if rfq.legs > 4: + return None # decline + return ParlayQuote(price=Decimal("0.30"), max_wager=Decimal("200")) + + pricer = NovigPricer(price, credentials=load_novig_rfq()) + async with pricer.stream() as stream: # the maker WebSocket + async for event in stream: + print(event) + + app = pricer.webhook_app() # or: uvicorn serves /quote, /confirm, /ping + ``` + + `price` gets each round as a `ParlayRFQ` and returns a `ParlayQuote`, or + `None` to decline; it may be a coroutine. It has until the auction + closes, less `margin` seconds for the answer's trip; a slower answer + declines. `confirm` gets each last look as a `LastLook`, with the + pricer's own quote on it, and returns whether to take the trade, within + `confirm_timeout` seconds or the trade is rejected. Without one, a trade + is confirmed when it matches the quote this pricer sent (`matches_quote`). + """ + + def __init__( + self, + price: PriceFn, + *, + confirm: ConfirmFn | None = None, + credentials: NovigRFQCredentials | None = None, + shared_secret: str | None = None, + env: str | None = None, + rfq: NovigRFQ | None = None, + margin: float = 0.25, + confirm_timeout: float = 0.75, + confirm_rate: ConfirmRate | None = None, + decline_below_min_wager: bool = True, + remember: float = 300.0, + ): + self.price = price + self.confirm = confirm + self.credentials = credentials or NovigRFQCredentials() + self.env = env or self.credentials.env + self.shared_secret = shared_secret or self.credentials.shared_secret + self._rfq = rfq + self.margin = margin + self.confirm_timeout = min(confirm_timeout, LAST_LOOK_SECONDS) + self.confirm_rate = confirm_rate or ConfirmRate() + self.decline_below_min_wager = decline_below_min_wager + self.remember = remember + self.stats = PricerStats() + self._rounds: dict[str, _Round] = {} + + @property + def rfq(self) -> NovigRFQ: + """The REST client, made on first use.""" + if self._rfq is None: + self._rfq = NovigRFQ(self.credentials, env=self.env) + return self._rfq + + async def close(self) -> None: + if self._rfq is not None: + await self._rfq.close() + + # -- rounds --------------------------------------------------------------- + + def _prune(self) -> None: + horizon = time.monotonic() - self.remember + for key in [k for k, r in self._rounds.items() if r.at < horizon]: + del self._rounds[key] + + def round_of(self, rfq_id: str) -> tuple[ParlayRFQ, ParlayQuote | None] | None: + """The round as this pricer saw it, and its quote (`None` declined).""" + held = self._rounds.get(rfq_id) + if held is None or not held.answer.done() or held.answer.cancelled(): + return None + return held.rfq, held.answer.result() + + async def quote(self, rfq: ParlayRFQ) -> ParlayQuote | None: + """The answer to a round: the pricing callback's quote, checked, or + `None` to decline. Priced once per `rfq_id`; a second transport + delivering the same round gets the same answer.""" + held = self._rounds.get(rfq.rfq_id) + if held is not None: + return await asyncio.shield(held.answer) + self._prune() + answer: asyncio.Future = asyncio.get_running_loop().create_future() + self._rounds[rfq.rfq_id] = _Round(rfq=rfq, answer=answer, at=time.monotonic()) + self.stats.rounds += 1 + try: + result = await self._price(rfq) + except BaseException: + answer.set_result(None) + raise + answer.set_result(result) + if result is None: + self.stats.declined += 1 + else: + self.stats.quoted += 1 + return result + + async def _price(self, rfq: ParlayRFQ) -> ParlayQuote | None: + if self.confirm_rate.locked(): + return None + budget = (rfq.deadline() - now_ms()) / 1000 - self.margin + if budget <= 0: + self.stats.late += 1 + return None + try: + quote = await asyncio.wait_for(_maybe(self.price(rfq)), timeout=budget) + except asyncio.TimeoutError: + self.stats.late += 1 + log.warning("novig rfq %s: the pricing callback took longer than %.2fs; declined", rfq.rfq_id, budget) + return None + except Exception: + self.stats.errors += 1 + log.exception("novig rfq %s: the pricing callback raised; declined", rfq.rfq_id) + return None + if quote is None: + return None + try: + quote = quote.checked() + except InvalidOrder as exc: + self.stats.errors += 1 + log.warning("novig rfq %s: %s; declined", rfq.rfq_id, exc) + return None + if self.decline_below_min_wager and quote.max_wager < rfq.min_wager: + log.info("novig rfq %s: max_wager %s is below the round's minimum %s; declined", + rfq.rfq_id, quote.max_wager, rfq.min_wager) + return None + return quote + + # -- last looks ----------------------------------------------------------- + + def with_round(self, look: LastLook) -> LastLook: + """The last look with this pricer's round and quote on it.""" + seen = self.round_of(look.rfq_id) + if seen is None: + return look + rfq, quote = seen + return replace(look, rfq=rfq, quote=quote) + + async def last_look(self, look: LastLook) -> bool: + """Whether to take a won trade: the confirm callback's answer within + `confirm_timeout`, or the default policy. A slow, failing or absent + answer rejects. Counted toward the confirm rate.""" + look = self.with_round(look) + self.stats.last_looks += 1 + budget = min(self.confirm_timeout, (look.deadline() - now_ms()) / 1000 - 0.05) + decision = False + if budget > 0: + try: + if self.confirm is None: + decision = matches_quote(look) + else: + decision = bool(await asyncio.wait_for(_maybe(self.confirm(look)), timeout=budget)) + except asyncio.TimeoutError: + log.warning("novig rfq %s: the confirm callback took longer than %.2fs; rejected", look.rfq_id, budget) + except Exception: + self.stats.errors += 1 + log.exception("novig rfq %s: the confirm callback raised; rejected", look.rfq_id) + self.confirm_rate.record(look.rfq_id, decision) + if decision: + self.stats.confirmed += 1 + else: + self.stats.rejected += 1 + return decision + + async def sync_standing(self) -> PricerRegistration | None: + """Read the registration and take Novig's confirm count and lock.""" + registration = await self.rfq.fetch_pricer() + if registration is not None: + self.confirm_rate.sync(registration.standing, registration.lock) + return registration + + # -- webhooks ------------------------------------------------------------- + + async def handle_webhook(self, route: str, raw_body: bytes, + signature: str | None) -> tuple[int, dict[str, Any] | None]: + """One webhook request as `(status, JSON body or None)`, for any web + framework. `route` is `quote`, `confirm` or `ping`; `raw_body` the + bytes exactly as received; `signature` the `X-Novig-Signature` + header. A bad signature is `401` and the body is never read.""" + if not self.shared_secret: + raise CredentialsMissing(f"{VENUE}: webhooks need the shared secret POST /rfq/pricer returned; " + "set NOVIG_RFQ_SHARED_SECRET") + if not signing.verify_webhook(self.shared_secret, raw_body, signature): + return 401, {"error": "invalid signature"} + received = now_ms() + name = route.strip("/").rsplit("/", 1)[-1] + if name == "ping": + return 200, {"ok": True} + try: + body = json.loads(raw_body) + except ValueError: + return 400, {"error": "invalid JSON"} + if not isinstance(body, dict) or "rfq_id" not in body: + return 400, {"error": "missing rfq_id"} + if name == "quote": + quote = await self.quote(ParlayRFQ.from_webhook(body, received_at=received)) + return (204, None) if quote is None else (200, quote.webhook_body()) + if name == "confirm": + return 200, {"confirmed": await self.last_look(LastLook.from_webhook(body, received_at=received))} + return 404, {"error": f"no route {route}"} + + def webhook_router(self) -> Any: + """A FastAPI router with `POST /quote`, `/confirm` and `/ping`, to + include under the path the webhook URL was registered with.""" + from fastapi import APIRouter + from fastapi.responses import JSONResponse, Response + + router = APIRouter() + + def endpoint_for(route: str) -> Callable[[Any], Awaitable[Any]]: + # A plain Starlette endpoint: the raw body is read, never parsed first. + async def endpoint(request: Any) -> Any: + status, body = await self.handle_webhook( + route, await request.body(), request.headers.get(signing.SIGNATURE_HEADER), + ) + return Response(status_code=status) if body is None else JSONResponse(body, status_code=status) + return endpoint + + for route in ("quote", "confirm", "ping"): + router.add_route(f"/{route}", endpoint_for(route), methods=["POST"], include_in_schema=False) + return router + + def webhook_app(self, *, prefix: str = "") -> Any: + """A FastAPI app serving the pricer's webhooks under `prefix` (the + path of the registered webhook URL, like `/novig`). Run it with + uvicorn behind HTTPS.""" + from fastapi import FastAPI + + app = FastAPI(title="Novig RFQ pricer", docs_url=None, redoc_url=None, openapi_url=None) + app.include_router(self.webhook_router(), prefix=prefix.rstrip("/")) + return app + + # -- the socket ----------------------------------------------------------- + + def stream(self, **kwargs: Any) -> Any: + """The maker WebSocket, answering rounds with this pricer: a + `synpath.ws.novig_rfq.NovigPricerStream`. Iterate it for every frame + as an event.""" + from ..ws.novig_rfq import NovigPricerStream + + return NovigPricerStream(self, **kwargs) diff --git a/synpath/trading/novig_rfq_signing.py b/synpath/trading/novig_rfq_signing.py new file mode 100644 index 0000000..90ba594 --- /dev/null +++ b/synpath/trading/novig_rfq_signing.py @@ -0,0 +1,187 @@ +"""Novig's parlay RFQ webhook signature (`X-Novig-Signature`), with no +network in sight. + +Every `/quote`, `/confirm` and `/ping` request Novig sends a pricer's +webhook carries + + X-Novig-Signature = hex(BLAKE3_keyed_hash(shared_secret_bytes, raw_body_bytes)) + +The key is the pricer's shared secret (64 lowercase hex characters from +`POST /rfq/pricer`, decoded to 32 bytes), the message is the request body +exactly as received, before any JSON parsing, and the output is 32 bytes as +64 lowercase hex characters. A request whose signature does not match is +answered `401` and never processed. + +BLAKE3 is not in the standard library. The `blake3` package is used when it +is installed; otherwise the keyed hash below, a direct transcription of the +BLAKE3 reference implementation, does the work. Webhook bodies are a few +hundred bytes, one chunk, so the pure version costs well under a millisecond. +It is checked against the official BLAKE3 test vectors +(`tests/samples/blake3_vectors.json`), and against the `blake3` package +where that is installed. +""" +from __future__ import annotations + +import hmac +import struct + +SIGNATURE_HEADER = "X-Novig-Signature" + +KEY_LEN = 32 +OUT_LEN = 32 +BLOCK_LEN = 64 +CHUNK_LEN = 1024 + +CHUNK_START = 1 << 0 +CHUNK_END = 1 << 1 +PARENT = 1 << 2 +ROOT = 1 << 3 +KEYED_HASH = 1 << 4 + +MASK = 0xFFFFFFFF +IV = (0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19) +PERMUTATION = (2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8) + + +# --------------------------------------------------------------------------- +# BLAKE3, keyed mode, 32-byte output +# --------------------------------------------------------------------------- + +def _g(s: list[int], a: int, b: int, c: int, d: int, x: int, y: int) -> None: + s[a] = (s[a] + s[b] + x) & MASK + v = s[d] ^ s[a] + s[d] = ((v >> 16) | (v << 16)) & MASK + s[c] = (s[c] + s[d]) & MASK + v = s[b] ^ s[c] + s[b] = ((v >> 12) | (v << 20)) & MASK + s[a] = (s[a] + s[b] + y) & MASK + v = s[d] ^ s[a] + s[d] = ((v >> 8) | (v << 24)) & MASK + s[c] = (s[c] + s[d]) & MASK + v = s[b] ^ s[c] + s[b] = ((v >> 7) | (v << 25)) & MASK + + +def _compress(cv: tuple[int, ...] | list[int], block: list[int], counter: int, block_len: int, + flags: int) -> list[int]: + s = [*cv[:8], *IV[:4], counter & MASK, (counter >> 32) & MASK, block_len, flags] + m = list(block) + for r in range(7): + _g(s, 0, 4, 8, 12, m[0], m[1]) + _g(s, 1, 5, 9, 13, m[2], m[3]) + _g(s, 2, 6, 10, 14, m[4], m[5]) + _g(s, 3, 7, 11, 15, m[6], m[7]) + _g(s, 0, 5, 10, 15, m[8], m[9]) + _g(s, 1, 6, 11, 12, m[10], m[11]) + _g(s, 2, 7, 8, 13, m[12], m[13]) + _g(s, 3, 4, 9, 14, m[14], m[15]) + if r < 6: + m = [m[i] for i in PERMUTATION] + for i in range(8): + s[i] ^= s[i + 8] + s[i + 8] ^= cv[i] + return s + + +def _words(block: bytes) -> list[int]: + return list(struct.unpack("<16I", block.ljust(BLOCK_LEN, b"\0"))) + + +class _Output: + """What a node would compress: its chaining value is the first eight + words; the root is the same compression with `ROOT` set.""" + + __slots__ = ("cv", "block", "counter", "block_len", "flags") + + def __init__(self, cv: tuple[int, ...] | list[int], block: list[int], counter: int, block_len: int, flags: int): + self.cv, self.block, self.counter, self.block_len, self.flags = cv, block, counter, block_len, flags + + def chaining_value(self) -> list[int]: + return _compress(self.cv, self.block, self.counter, self.block_len, self.flags)[:8] + + def root(self) -> bytes: + words = _compress(self.cv, self.block, 0, self.block_len, self.flags | ROOT)[:8] + return struct.pack("<8I", *words) + + +def _chunk(key: tuple[int, ...], chunk: bytes, counter: int, flags: int) -> _Output: + cv: tuple[int, ...] | list[int] = key + blocks = [chunk[i:i + BLOCK_LEN] for i in range(0, len(chunk), BLOCK_LEN)] or [b""] + for index, block in enumerate(blocks[:-1]): + start = CHUNK_START if index == 0 else 0 + cv = _compress(cv, _words(block), counter, BLOCK_LEN, flags | start)[:8] + last = blocks[-1] + start = CHUNK_START if len(blocks) == 1 else 0 + return _Output(cv, _words(last), counter, len(last), flags | start | CHUNK_END) + + +def _parent(left: list[int], right: list[int], key: tuple[int, ...], flags: int) -> _Output: + return _Output(key, [*left, *right], 0, BLOCK_LEN, flags | PARENT) + + +def blake3_keyed_hash_pure(key: bytes, data: bytes) -> bytes: + """BLAKE3 in keyed mode, 32 bytes out, in pure Python.""" + if len(key) != KEY_LEN: + raise ValueError(f"a BLAKE3 key is {KEY_LEN} bytes, got {len(key)}") + key_words = struct.unpack("<8I", key) + flags = KEYED_HASH + chunks = [data[i:i + CHUNK_LEN] for i in range(0, len(data), CHUNK_LEN)] or [b""] + stack: list[list[int]] = [] + output = _chunk(key_words, chunks[0], 0, flags) + for counter in range(1, len(chunks)): + cv = output.chaining_value() + total = counter + while total & 1 == 0: + cv = _parent(stack.pop(), cv, key_words, flags).chaining_value() + total >>= 1 + stack.append(cv) + output = _chunk(key_words, chunks[counter], counter, flags) + while stack: + output = _parent(stack.pop(), output.chaining_value(), key_words, flags) + return output.root() + + +def blake3_keyed_hash(key: bytes, data: bytes) -> bytes: + """BLAKE3 in keyed mode, 32 bytes out: the `blake3` package when it is + installed, the pure version otherwise.""" + try: + from blake3 import blake3 # type: ignore[import-not-found] + except ImportError: + return blake3_keyed_hash_pure(key, data) + if len(key) != KEY_LEN: + raise ValueError(f"a BLAKE3 key is {KEY_LEN} bytes, got {len(key)}") + return blake3(data, key=key).digest() + + +# --------------------------------------------------------------------------- +# The webhook signature +# --------------------------------------------------------------------------- + +def secret_bytes(shared_secret: str | bytes) -> bytes: + """The shared secret as the 32-byte key: 64 hex characters as Novig + returns it, or the raw 32 bytes.""" + if isinstance(shared_secret, bytes) and len(shared_secret) == KEY_LEN: + return shared_secret + text = shared_secret.decode() if isinstance(shared_secret, bytes) else shared_secret + try: + key = bytes.fromhex(text.strip()) + except ValueError: + raise ValueError("the Novig RFQ shared secret is 64 hex characters (32 bytes)") from None + if len(key) != KEY_LEN: + raise ValueError(f"the Novig RFQ shared secret is 64 hex characters (32 bytes), got {len(key)} bytes") + return key + + +def sign_webhook(shared_secret: str | bytes, raw_body: bytes) -> str: + """The `X-Novig-Signature` value for a body: what Novig sends, and what a + test sends to a local pricer.""" + return blake3_keyed_hash(secret_bytes(shared_secret), raw_body).hex() + + +def verify_webhook(shared_secret: str | bytes, raw_body: bytes, signature: str | None) -> bool: + """Whether `signature` is Novig's over these exact body bytes, compared + in constant time. A missing or malformed header is `False`.""" + if not signature: + return False + expected = sign_webhook(shared_secret, raw_body) + return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) diff --git a/synpath/ws/novig_rfq.py b/synpath/ws/novig_rfq.py new file mode 100644 index 0000000..f1900d2 --- /dev/null +++ b/synpath/ws/novig_rfq.py @@ -0,0 +1,251 @@ +"""Novig's parlay RFQ sockets: the maker WebSocket a pricer answers rounds +on, and the public tape of every RFQ execution. + +**The maker WebSocket** (`wss://api.novig.com/rfq/ws`, QA +`wss://api-qa.novig.us/rfq/ws`) takes the market-maker bearer token on the +upgrade and refuses an account with no pricer registration (`403`). Frames +are JSON `{event, data}`; Novig adds `timestamp` to its own. After every +connect the stream subscribes to both channels: `rfq` (`rfq_created`, +`rfq_closed`, `rfq_executed`, to every pricer) and `quotes` (`quote_created`, +`quote_accepted`, `quote_executed`, `quote_unfilled`, `quote_dropped`, to +this pricer alone). Without `quotes` no last look arrives and every win is +rejected. + +A round opens with `rfq_created`: the stream hands it to the pricer and +sends `create_quote` with the answer, or `decline`, before `expires_at`. A +win arrives as `quote_accepted`, answered with `confirm` within 1 second +under Novig's id for the quote. Every message sent gets one ack or one +`error`; an error never closes the connection. A `FORBIDDEN` error naming a +confirm-rate lock records the lock, and the pricer stops quoting until it +ends. + +Novig neither replays nor backfills: a reconnect starts at the live edge, +so it is marked `reconcile_required`; read the RFQs won from +`NovigRFQ.reconcile_executions`. Novig pings every 15 s and the WebSocket +library answers. + +Every frame is emitted as a `VenueEvent` named by its `event`, with `data` +as the payload, so a caller sees the rounds, its quotes' fates and the acks. + +**The execution feed** (`/rfq/executions/feed`) carries every RFQ execution +on Novig, naming no pricer, each with an `offset` that grows by one; a jump +is a gap (`StreamStatusEvent(state="gap", reconcile_required=True)`), filled +from `NovigRFQ.reconcile_executions`. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from ..trading import novig_rfq as rfq_api +from ..trading.credentials import NovigRFQCredentials +from ..trading.novig_rfq import LastLook, NovigPricer, ParlayRFQ, lock_of_message, ms_of +from .base import Event, Stream, VenueEvent, now_ms + +log = logging.getLogger("synpath.ws.novig_rfq") + +VENUE = "novig" +CHANNELS = ("rfq", "quotes") + +FINAL_STATUSES = { + 401: "the bearer token is missing or invalid", + 403: "not a market-maker token, or no pricer registered (POST /rfq/pricer first)", + 404: "the RFQ socket is off in this environment", +} +"""Upgrade refusals Novig says not to retry.""" + + +def _status_of(exc: BaseException) -> int | None: + response = getattr(exc, "response", None) + code = getattr(response, "status_code", None) or getattr(exc, "status_code", None) + return int(code) if code else None + + +class _Bearer(Stream): + """A socket authenticated by the market-maker bearer token on the upgrade.""" + + venue = VENUE + + def __init__(self, url: str, *, token: Any, **kwargs: Any): + self.token = token + self._bearer: str | None = None + super().__init__(url, **kwargs) + + def headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._bearer}"} if self._bearer else {} + + async def _open(self) -> Any: + try: + self._bearer = await self.token.get() + except Exception as exc: + self._fail(f"no bearer token: {exc}") + raise + try: + return await super()._open() + except Exception as exc: + code = _status_of(exc) + if code in FINAL_STATUSES: + if code == 401: + self.token.invalidate() + self._fail(f"upgrade refused ({code}): {FINAL_STATUSES[code]}") + raise + + def _fail(self, detail: str) -> None: + self._closing = True + self.status("failed", detail) + from .base import _CLOSED + + self._queue.put_nowait(_CLOSED) + + def _later(self, coroutine: Any) -> asyncio.Task | None: + try: + return asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + coroutine.close() + return None + + def _event(self, message: dict[str, Any]) -> VenueEvent: + data = message.get("data") + return VenueEvent(venue=VENUE, name=str(message.get("event") or ""), + payload=data if isinstance(data, dict) else {"data": data}, + timestamp=ms_of(message.get("timestamp"))) + + +class NovigPricerStream(_Bearer): + """The maker WebSocket, answering every round with a `NovigPricer`.""" + + name = "rfq" + private = True + + def __init__(self, pricer: NovigPricer, *, url: str | None = None, **kwargs: Any): + self.pricer = pricer + env = pricer.env + if env not in rfq_api.WS_URLS: + raise ValueError(f"{VENUE}: the RFQ env is production or qa, got {env!r}") + self._pricing: dict[str, asyncio.Task] = {} + self._confirms: dict[str, asyncio.Task] = {} + self._confirmed: dict[str, bool] = {} + super().__init__(url or rfq_api.WS_URLS[env], token=pricer.rfq.token, **kwargs) + + async def on_connect(self) -> None: + for channel in CHANNELS: + await self.send({"event": "subscribe", "data": channel}) + + def on_disconnect(self) -> None: + # A round being priced cannot be answered on the next connection. + for task in self._pricing.values(): + task.cancel() + self._pricing.clear() + + async def close(self) -> None: + for task in [*self._pricing.values(), *self._confirms.values()]: + task.cancel() + self._pricing.clear() + self._confirms.clear() + await super().close() + + # -- frames --------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + name = message.get("event") + data = message.get("data") if isinstance(message.get("data"), dict) else {} + if name == "error": + self._error(data) + return [] + if name in ("subscribed", "unsubscribed"): + self.status("subscribed" if name == "subscribed" else "error", + f"{name} {data.get('channel', '')}".strip()) + return [] + if name == "rfq_created": + rfq = ParlayRFQ.from_ws(data, received_at=now_ms()) + task = self._later(self._answer(rfq)) + if task is not None: + self._pricing[rfq.rfq_id] = task + elif name == "rfq_closed": + task = self._pricing.pop(str(data.get("rfq_id")), None) + if task is not None and not task.done(): + task.cancel() + elif name == "quote_accepted": + look = LastLook.from_ws(data, received_at=now_ms()) + task = self._later(self._confirm(look)) + if task is not None: + self._confirms[look.rfq_id] = task + return [self._event(message)] + + def _error(self, data: dict[str, Any]) -> None: + code, text, rfq_id = str(data.get("code") or ""), str(data.get("message") or ""), data.get("rfq_id") + if code == "FORBIDDEN": + lock = lock_of_message(text) + if lock is not None: + self.pricer.confirm_rate.locked_out(until=lock.until, source=lock.source) + elif code == "NO_CONFIRM_WINDOW" and rfq_id and self._confirmed.get(str(rfq_id)): + # The confirm arrived after the window: Novig counts a miss. + self.pricer.confirm_rate.amend(str(rfq_id), False) + detail = f"novig refused: {code} {text}".strip() + self.status("error", detail + (f" (rfq {rfq_id})" if rfq_id else ""), key=str(rfq_id) if rfq_id else None) + + # -- answering ------------------------------------------------------------ + + async def _answer(self, rfq: ParlayRFQ) -> None: + try: + quote = await self.pricer.quote(rfq) + if quote is not None: + await self.send({"event": "create_quote", "data": quote.ws_data(rfq.rfq_id)}) + elif not self.pricer.confirm_rate.locked(): + # A decline says the pricer is up; silence looks like one that is down. + await self.send({"event": "decline", "data": {"rfq_id": rfq.rfq_id}}) + except asyncio.CancelledError: + raise + except Exception as exc: + self.status("error", f"answering rfq {rfq.rfq_id}: {type(exc).__name__}: {exc}", key=rfq.rfq_id) + finally: + self._pricing.pop(rfq.rfq_id, None) + + async def _confirm(self, look: LastLook) -> None: + try: + decision = await self.pricer.last_look(look) + self._confirmed[look.rfq_id] = decision + if len(self._confirmed) > 1000: + self._confirmed.pop(next(iter(self._confirmed))) + sent = await self.send({"event": "confirm", "data": { + "rfq_id": look.rfq_id, "quote_id": look.quote_id, "confirmed": decision, + }}) + if not sent and decision: + # The connection dropped before the answer: Novig rejects the trade. + self.pricer.confirm_rate.amend(look.rfq_id, False) + except asyncio.CancelledError: + raise + except Exception as exc: + self.status("error", f"confirming rfq {look.rfq_id}: {type(exc).__name__}: {exc}", key=look.rfq_id) + finally: + self._confirms.pop(look.rfq_id, None) + + +class NovigExecutionFeed(_Bearer): + """Every RFQ execution on Novig as it happens (`rfq_executed`), naming no + pricer. Needs a bearer token, not a pricer registration.""" + + name = "rfq_executions" + + def __init__(self, credentials: NovigRFQCredentials | None = None, *, env: str | None = None, + url: str | None = None, rfq: rfq_api.NovigRFQ | None = None, **kwargs: Any): + rfq = rfq or rfq_api.NovigRFQ(credentials, env=env) + self.rfq = rfq + self.last_offset: int | None = None + super().__init__(url or rfq_api.FEED_URLS[rfq.env], token=rfq.token, **kwargs) + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict) or message.get("event") != "rfq_executed": + return [] + data = message.get("data") if isinstance(message.get("data"), dict) else {} + offset = data.get("offset") + if isinstance(offset, int): + if self.last_offset is not None and offset > self.last_offset + 1: + self.status("gap", f"offsets {self.last_offset + 1} to {offset - 1} missed; " + "reconcile from NovigRFQ.reconcile_executions", reconcile=True) + if self.last_offset is None or offset > self.last_offset: + self.last_offset = offset + return [self._event(message)] diff --git a/tests/samples/blake3_vectors.json b/tests/samples/blake3_vectors.json new file mode 100644 index 0000000..cad2ff4 --- /dev/null +++ b/tests/samples/blake3_vectors.json @@ -0,0 +1,67 @@ +{ + "_source": "https://github.com/BLAKE3-team/BLAKE3/blob/master/test_vectors/test_vectors.json (the official BLAKE3 test vectors, trimmed to 32-byte outputs and a subset of lengths)", + "_input": "input_len bytes of the repeating sequence 0, 1, ..., 250, 0, 1, ...", + "key": "whats the Elvish word for friend", + "cases": [ + { + "input_len": 0, + "keyed_hash": "92b2b75604ed3c761f9d6f62392c8a9227ad0ea3f09573e783f1498a4ed60d26" + }, + { + "input_len": 1, + "keyed_hash": "6d7878dfff2f485635d39013278ae14f1454b8c0a3a2d34bc1ab38228a80c95b" + }, + { + "input_len": 63, + "keyed_hash": "bb1eb5d4afa793c1ebdd9fb08def6c36d10096986ae0cfe148cd101170ce37ae" + }, + { + "input_len": 64, + "keyed_hash": "ba8ced36f327700d213f120b1a207a3b8c04330528586f414d09f2f7d9ccb7e6" + }, + { + "input_len": 65, + "keyed_hash": "c0a4edefa2d2accb9277c371ac12fcdbb52988a86edc54f0716e1591b4326e72" + }, + { + "input_len": 1023, + "keyed_hash": "c951ecdf03288d0fcc96ee3413563d8a6d3589547f2c2fb36d9786470f1b9d6e" + }, + { + "input_len": 1024, + "keyed_hash": "75c46f6f3d9eb4f55ecaaee480db732e6c2105546f1e675003687c31719c7ba4" + }, + { + "input_len": 1025, + "keyed_hash": "357dc55de0c7e382c900fd6e320acc04146be01db6a8ce7210b7189bd664ea69" + }, + { + "input_len": 2048, + "keyed_hash": "879cf1fa2ea0e79126cb1063617a05b6ad9d0b696d0d757cf053439f60a99dd1" + }, + { + "input_len": 2049, + "keyed_hash": "9f29700902f7c86e514ddc4df1e3049f258b2472b6dd5267f61bf13983b78dd5" + }, + { + "input_len": 3072, + "keyed_hash": "044a0e7b172a312dc02a4c9a818c036ffa2776368d7f528268d2e6b5df191770" + }, + { + "input_len": 4097, + "keyed_hash": "00df940cd36bb9fa7cbbc3556744e0dbc8191401afe70520ba292ee3ca80abbc" + }, + { + "input_len": 8193, + "keyed_hash": "954a2a75420c8d6547e3ba5b98d963e6fa6491addc8c023189cc519821b4a1f5" + }, + { + "input_len": 16384, + "keyed_hash": "9e9fc4eb7cf081ea7c47d1807790ed211bfec56aa25bb7037784c13c4b707b0d" + }, + { + "input_len": 31744, + "keyed_hash": "efa53b389ab67c593dba624d898d0f7353ab99e4ac9d42302ee64cbf9939a419" + } + ] +} diff --git a/tests/samples/novig_rfq_docs.json b/tests/samples/novig_rfq_docs.json new file mode 100644 index 0000000..b5db0f1 --- /dev/null +++ b/tests/samples/novig_rfq_docs.json @@ -0,0 +1,248 @@ +{ + "_source": "Examples from Novig's RFQ documentation (docs.novig.com/api-reference/rfq: websocket, webhooks, registration, confirm-rate, executions), copied as published. Documented examples, not recorded traffic.", + "ws": { + "rfq_created": { + "event": "rfq_created", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "outcome_ids": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222" + ], + "expires_at": "2026-01-01T00:00:03Z", + "min_wager": "15" + }, + "timestamp": "2026-01-01T00:00:00.123Z" + }, + "rfq_closed": { + "event": "rfq_closed", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001" + }, + "timestamp": "2026-01-01T00:00:03.001Z" + }, + "rfq_executed": { + "event": "rfq_executed", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "wager": "50", + "price": "0.50", + "executed_at": "2023-11-14T22:13:20Z" + }, + "timestamp": "2023-11-14T22:13:20.050Z" + }, + "quote_created": { + "event": "quote_created", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff" + }, + "timestamp": "2026-01-01T00:00:03.001Z" + }, + "quote_accepted": { + "event": "quote_accepted", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "wager": "50", + "price": "0.55", + "external_flag": "mm-1" + }, + "timestamp": "2026-01-01T00:00:05.250Z" + }, + "quote_executed": { + "event": "quote_executed", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "wager": "50", + "price": "0.55", + "executed_at": "2023-11-14T22:13:20Z" + }, + "timestamp": "2023-11-14T22:13:20.050Z" + }, + "quote_unfilled": { + "event": "quote_unfilled", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "reason": "taker_insolvent" + }, + "timestamp": "2023-11-14T22:13:20.050Z" + }, + "quote_dropped": { + "event": "quote_dropped", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "reason": "below_min_wager", + "min_wager": "15" + }, + "timestamp": "2026-01-01T00:00:03.001Z" + }, + "subscribed": { + "event": "subscribed", + "data": { + "channel": "rfq" + }, + "timestamp": "2026-01-01T00:00:00.123Z" + }, + "quote_submitted": { + "event": "quote_submitted", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001" + }, + "timestamp": "2026-01-01T00:00:01.500Z" + }, + "confirm_recorded": { + "event": "confirm_recorded", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "quote_id": "0193abcd-0000-7000-8000-00000000000a" + }, + "timestamp": "2026-01-01T00:00:05.400Z" + }, + "error_rfq_closed": { + "event": "error", + "data": { + "code": "RFQ_CLOSED", + "message": "The quote round has already closed.", + "rfq_id": "0193abcd-0000-7000-8000-000000000001" + }, + "timestamp": "2026-01-01T00:00:03.100Z" + }, + "error_locked": { + "event": "error", + "data": { + "code": "FORBIDDEN", + "message": "Maker is locked out of RFQ quoting until 2026-10-06T18:28:00Z for a low confirm rate.", + "rfq_id": "0193abcd-0000-7000-8000-000000000002" + }, + "timestamp": "2026-10-06T18:24:10.000Z" + }, + "create_quote": { + "event": "create_quote", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "price": "0.45", + "max_wager": "250", + "quote_id": "mm-1" + } + }, + "confirm": { + "event": "confirm", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "confirmed": true + } + } + }, + "webhook": { + "quote_request": { + "rfq_id": "9b1d76e0-2fa9-0c1d-8e74-b3a5f6c218e0", + "outcome_ids": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222" + ], + "timestamp": "2026-04-29T18:42:11.123Z", + "min_wager": "50.00" + }, + "quote_response": { + "price": 0.45, + "max_wager": "250.00", + "quote_id": "pricer-quote-xyz" + }, + "confirm_request": { + "rfq_id": "9b1d76e0-2fa9-0c1d-8e74-b3a5f6c218e0", + "wager": "100.00", + "price": "0.45", + "external_flag": "pricer-quote-xyz" + }, + "confirm_response": { + "confirmed": true + }, + "ping_request": { + "pricer_id": "0193abcd-0000-7000-8000-000000000001", + "timestamp": "2026-04-29T18:42:11.123Z" + } + }, + "rest": { + "register_response": { + "pricerId": "0193abcd-0000-7000-8000-000000000001", + "webhookUrl": "https://pricer.example.com/novig", + "sharedSecret": null + }, + "pricer": { + "pricerId": "0193abcd-0000-7000-8000-000000000001", + "webhookUrl": "https://pricer.example.com/novig", + "confirmStanding": { + "lookbackMinutes": 30, + "counted": 34, + "confirmed": 29, + "threshold": "0.60", + "minSample": 20 + }, + "lock": null + }, + "pricer_locked": { + "pricerId": "0193abcd-0000-7000-8000-000000000001", + "webhookUrl": "https://pricer.example.com/novig", + "confirmStanding": { + "lookbackMinutes": 30, + "counted": 0, + "confirmed": 0, + "threshold": "0.60", + "minSample": 20 + }, + "lock": { + "until": "2026-10-06T18:28:00Z", + "source": "confirm_rate" + } + }, + "ping": { + "url": "https://pricer.example.com/novig/ping", + "ok": true, + "status": 200, + "error": null + }, + "executions": [ + { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "parlay_id": "0193abcd-0000-7000-8000-0000000000aa", + "wager": "42.00", + "price": "0.42", + "liability": "58.00", + "status": "settled", + "result": "fmv", + "fmv_value": "0.541", + "outcome_ids": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222" + ], + "executed_at": "2026-06-16T22:13:20Z", + "settled_at": "2026-06-17T03:41:09Z" + } + ], + "collateral": { + "rfq_collateral": "300.00", + "rfqs_filled": 2, + "timestamp": "2026-09-23T21:36:32.123456Z" + }, + "feed_frame": { + "event": "rfq_executed", + "data": { + "offset": 12345, + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "wager": "50", + "price": "0.50", + "executed_at": "2026-06-16T22:13:20Z" + } + } + } +} diff --git a/tests/test_novig_rfq.py b/tests/test_novig_rfq.py new file mode 100644 index 0000000..eccae9d --- /dev/null +++ b/tests/test_novig_rfq.py @@ -0,0 +1,887 @@ +"""Novig's parlay RFQ, the pricer's side: the webhook signature against the +official BLAKE3 vectors, quotes and last looks, the pot and settlement, +eligibility, the confirm rate, the webhook handlers, the maker WebSocket +against a fake socket, and the REST reads against a scripted venue. Frames +and bodies are the examples in Novig's RFQ documentation +(`tests/samples/novig_rfq_docs.json`); nothing here reaches the venue.""" +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import httpx +import pytest + +from synpath.trading import novig_rfq_signing as signing +from synpath.trading.credentials import CredentialsMissing, NovigRFQCredentials, load_novig_rfq +from synpath.trading.errors import InvalidOrder, PermissionDenied +from synpath.trading.novig_rfq import ( + ConfirmRate, ConfirmStanding, LastLook, NovigPricer, NovigRFQ, ParlayLeg, ParlayQuote, ParlayRFQ, PricerLock, + PricerRegistration, RFQCollateral, RFQExecution, collateral, eligibility_problems, is_eligible, lock_of_message, + matches_quote, ms_of, pot, round_price, settle, settlement_scalar, +) +from synpath.ws.base import StreamStatusEvent, VenueEvent +from synpath.ws.novig_rfq import NovigExecutionFeed, NovigPricerStream + +from conftest import load + +DOCS = load("novig_rfq_docs.json") +WS, HOOK, REST = DOCS["ws"], DOCS["webhook"], DOCS["rest"] +VECTORS = load("blake3_vectors.json") +SECRET = "0f1e2d3c4b5a69788796a5b4c3d2e1f000112233445566778899aabbccddeeff" +"""A made-up shared secret: 64 hex characters, as Novig issues them.""" +RFQ_ID = WS["rfq_created"]["data"]["rfq_id"] +HOOK_RFQ = HOOK["quote_request"]["rfq_id"] + + +def vector_input(length: int) -> bytes: + return bytes(i % 251 for i in range(length)) + + +def iso(offset: float = 0.0) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=offset)).isoformat().replace("+00:00", "Z") + + +def fresh(frame: dict, **data) -> dict: + """A documented frame with its times moved to now.""" + out = json.loads(json.dumps(frame)) + out["data"].update(data) + return out + + +def token_creds(**changes) -> NovigRFQCredentials: + base = {"access_token": "test-token", "shared_secret": SECRET, "env": "qa"} + return NovigRFQCredentials(**{**base, **changes}) + + +def constant(price="0.45", wager="250", quote_id="mm-1"): + calls = [] + + async def price_fn(rfq): + calls.append(rfq) + return ParlayQuote(price=Decimal(price), max_wager=Decimal(wager), quote_id=quote_id) + + price_fn.calls = calls # type: ignore[attr-defined] + return price_fn + + +# --------------------------------------------------------------------------- +# The webhook signature +# --------------------------------------------------------------------------- + +class TestSignature: + @pytest.mark.parametrize("case", VECTORS["cases"], ids=lambda c: str(c["input_len"])) + def test_blake3_keyed_hash_matches_the_official_vectors(self, case): + key = VECTORS["key"].encode() + assert signing.blake3_keyed_hash_pure(key, vector_input(case["input_len"])).hex() == case["keyed_hash"] + + @pytest.mark.parametrize("case", VECTORS["cases"][:6], ids=lambda c: str(c["input_len"])) + def test_the_signature_is_the_keyed_hash_under_the_hex_secret(self, case): + secret_hex = VECTORS["key"].encode().hex() + body = vector_input(case["input_len"]) + assert signing.sign_webhook(secret_hex, body) == case["keyed_hash"] + assert signing.verify_webhook(secret_hex, body, case["keyed_hash"]) + + def test_the_installed_blake3_package_agrees_when_there_is_one(self): + blake3 = pytest.importorskip("blake3") + body = json.dumps(HOOK["quote_request"]).encode() + key = bytes.fromhex(SECRET) + assert blake3.blake3(body, key=key).digest() == signing.blake3_keyed_hash_pure(key, body) + + def test_a_body_signs_and_verifies_byte_for_byte(self): + raw = json.dumps(HOOK["quote_request"], separators=(",", ":")).encode() + signature = signing.sign_webhook(SECRET, raw) + assert len(signature) == 64 and signature == signature.lower() + assert signing.verify_webhook(SECRET, raw, signature) + assert signing.verify_webhook(SECRET, raw, signature.upper()) + # Re-serialized JSON is different bytes. + assert not signing.verify_webhook(SECRET, json.dumps(json.loads(raw), indent=1).encode(), signature) + assert not signing.verify_webhook(SECRET, raw + b" ", signature) + assert not signing.verify_webhook("ab" * 32, raw, signature) + assert not signing.verify_webhook(SECRET, raw, None) + assert not signing.verify_webhook(SECRET, raw, "") + + def test_the_secret_is_64_hex_characters(self): + assert signing.secret_bytes(SECRET) == bytes.fromhex(SECRET) + assert signing.secret_bytes(bytes.fromhex(SECRET)) == bytes.fromhex(SECRET) + for bad in ("abc", "zz" * 32, "ab" * 31): + with pytest.raises(ValueError): + signing.secret_bytes(bad) + + +# --------------------------------------------------------------------------- +# Rounds, quotes and last looks +# --------------------------------------------------------------------------- + +class TestRecords: + def test_a_round_from_the_socket(self): + rfq = ParlayRFQ.from_ws(WS["rfq_created"]["data"]) + assert rfq.rfq_id == RFQ_ID and rfq.legs == 2 and rfq.transport == "websocket" + assert rfq.min_wager == Decimal("15") + assert rfq.expires_at == ms_of("2026-01-01T00:00:03Z") + assert rfq.outcome_ids[0] == "11111111-1111-1111-1111-111111111111" + + def test_a_round_from_the_webhook_closes_three_seconds_after_it_was_made(self): + rfq = ParlayRFQ.from_webhook(HOOK["quote_request"]) + assert rfq.min_wager == Decimal("50.00") and rfq.transport == "webhook" + assert rfq.expires_at - rfq.created_at == 3000 + assert rfq.created_at == ms_of("2026-04-29T18:42:11.123Z") + + def test_without_a_minimum_the_platform_minimum_applies(self): + body = {k: v for k, v in HOOK["quote_request"].items() if k != "min_wager"} + assert ParlayRFQ.from_webhook(body).min_wager == Decimal("10.00") + assert ParlayRFQ.from_ws({**WS["rfq_created"]["data"], "min_wager": "5"}).min_wager == Decimal("10.00") + + def test_times(self): + assert ms_of("2026-09-23T21:36:32.123456Z") == ms_of("2026-09-23T21:36:32.123Z") + assert ms_of("2026-01-01T00:00:00.123456789Z") == ms_of("2026-01-01T00:00:00.123Z") + assert ms_of(None) is None and ms_of("nonsense") is None + + def test_prices_round_up_onto_the_grid(self): + assert round_price("0.4501") == Decimal("0.451") + assert round_price("0.45") == Decimal("0.450") + assert ParlayQuote(Decimal("0.12345"), Decimal("100")).checked().price == Decimal("0.124") + + @pytest.mark.parametrize("price,wager,quote_id", [ + ("0", "100", None), ("0.9995", "100", None), ("1.2", "100", None), ("0.5", "0", None), + ("0.5", "-1", None), ("0.5", "100", "x" * 257), + ]) + def test_quotes_novig_would_drop_are_refused(self, price, wager, quote_id): + with pytest.raises(InvalidOrder): + ParlayQuote(Decimal(price), Decimal(wager), quote_id).checked() + + def test_a_quote_on_each_transport(self): + quote = ParlayQuote(Decimal("0.45"), Decimal("250.00"), "pricer-quote-xyz").checked() + assert quote.webhook_body() == HOOK["quote_response"] + socket = ParlayQuote(Decimal("0.45"), Decimal("250"), "mm-1").checked().ws_data(RFQ_ID) + assert socket == {**WS["create_quote"]["data"], "price": "0.450"} + assert all(isinstance(socket[k], str) for k in ("price", "max_wager")) + assert "quote_id" not in ParlayQuote(Decimal("0.45"), Decimal("250")).checked().ws_data(RFQ_ID) + + def test_a_last_look_from_each_transport(self): + look = LastLook.from_ws(WS["quote_accepted"]["data"]) + assert look.quote_id == "0193abcd-0000-7000-8000-00000000000a" and look.external_flag == "mm-1" + assert look.wager == Decimal("50") and look.price == Decimal("0.55") + hook = LastLook.from_webhook(HOOK["confirm_request"]) + assert hook.quote_id is None and hook.external_flag == "pricer-quote-xyz" + assert hook.wager == Decimal("100.00") and hook.transport == "webhook" + assert hook.deadline() - hook.received_at == 1000 + + def test_the_default_policy_confirms_only_its_own_quote(self): + quote = ParlayQuote(Decimal("0.45"), Decimal("250"), "pricer-quote-xyz").checked() + look = LastLook.from_webhook(HOOK["confirm_request"]) + from dataclasses import replace + assert not matches_quote(look) + assert matches_quote(replace(look, quote=quote)) + assert not matches_quote(replace(look, quote=quote, wager=Decimal("250.01"))) + assert not matches_quote(replace(look, quote=quote, price=Decimal("0.44"))) + assert not matches_quote(replace(look, quote=quote, external_flag="someone-else")) + + +# --------------------------------------------------------------------------- +# The pot and settlement +# --------------------------------------------------------------------------- + +class TestSettlement: + def test_the_pot_and_collateral(self): + assert pot("42", "0.42") == Decimal("100.00") and collateral("42", "0.42") == Decimal("58.00") + assert pot("10", "0.3") == Decimal("33.33") and collateral("10", "0.3") == Decimal("23.33") + + @pytest.mark.parametrize("values,scalar", [ + ([0, None, None], Decimal("0")), ([0, "0.5", 1], Decimal("0")), ([1, "0.5", 1], Decimal("0.5")), + (["0.5", "0.6", 1], Decimal("0.3")), ([1, 1, 1], Decimal("1")), ([1, None, 1], None), + (["0.01", "0.01"], Decimal("0.001")), + ]) + def test_the_documented_scalars(self, values, scalar): + assert settlement_scalar(values) == scalar + + def test_a_voided_leg_splits_the_pot(self): + out = settle("42", "0.42", [1, "0.5", 1]) + assert out.result == "fmv" and out.taker_payout == Decimal("50.00") and out.pricer_payout == Decimal("50.00") + assert out.pricer_pnl == Decimal("-8.00") + loss = settle("42", "0.42", [0, None]) + assert loss.result == "loss" and loss.pricer_payout == Decimal("100.00") and loss.pricer_pnl == Decimal("42.00") + assert settle("42", "0.42", [1, None]).result == "open" + + def test_an_execution_and_its_payout(self): + row = RFQExecution.from_row(REST["executions"][0]) + assert row.pot == Decimal("100.00") and row.liability == Decimal("58.00") + assert row.result == "fmv" and row.fmv_value == Decimal("0.541") + assert row.pricer_payout == Decimal("45.90") and row.pricer_pnl == Decimal("-12.10") + assert row.executed_at == ms_of("2026-06-16T22:13:20Z") and len(row.outcome_ids) == 2 + + +# --------------------------------------------------------------------------- +# Eligibility +# --------------------------------------------------------------------------- + +class TestEligibility: + def leg(self, n, **kw): + return ParlayLeg(outcome_id=f"o{n}", **kw) + + def test_two_to_twenty_legs(self): + assert eligibility_problems([self.leg(1)]) + assert is_eligible([self.leg(1, event_id="a"), self.leg(2, event_id="b")]) + assert eligibility_problems([self.leg(n, event_id=f"e{n}") for n in range(21)]) + + def test_leg_rules(self): + assert eligibility_problems([self.leg(1, special=True), self.leg(2)]) + assert eligibility_problems([self.leg(1, yes_no=True, no_side=True), self.leg(2)]) + assert not eligibility_problems([self.leg(1, yes_no=True), self.leg(2)]) + assert eligibility_problems([self.leg(1, open=False), self.leg(2)]) + assert eligibility_problems([self.leg(1), self.leg(1)]) + + def test_same_game_lines(self): + game = {"event_id": "g1"} + assert eligibility_problems([self.leg(1, market_type="spread", **game), self.leg(2, market_type="moneyline", **game)]) + assert eligibility_problems([self.leg(1, market_type="total", **game), self.leg(2, market_type="total", **game)]) + assert is_eligible([self.leg(1, market_type="spread", **game), self.leg(2, market_type="total", **game)]) + assert is_eligible([self.leg(1, market_type="spread", **game), + self.leg(2, market_type="spread", period="first_half", **game)]) + assert eligibility_problems([self.leg(1, market_type="first_touchdown_scorer", **game), + self.leg(2, market_type="first_touchdown_scorer", **game)]) + assert eligibility_problems([self.leg(1, stats=frozenset({"power_play_points"}), player_id="p1", **game), + self.leg(2, stats=frozenset({"goals"}), player_id="p2", **game)]) + + def test_same_player(self): + game = {"event_id": "g1", "player_id": "p1"} + assert eligibility_problems([self.leg(1, market_type="player_points", **game), + self.leg(2, market_type="player_points", **game)]) + assert eligibility_problems([ + self.leg(1, market_type="player_pra", stats=frozenset({"points", "rebounds", "assists"}), **game), + self.leg(2, market_type="player_points", stats=frozenset({"points"}), **game)]) + assert is_eligible([self.leg(1, market_type="player_rebounds", stats=frozenset({"rebounds"}), **game), + self.leg(2, market_type="player_points", stats=frozenset({"points"}), **game)]) + + def test_different_games(self): + assert eligibility_problems([self.leg(1, event_id="g1", team_ids=("a", "b")), + self.leg(2, event_id="g2", team_ids=("b", "c"))]) + assert eligibility_problems([self.leg(1, event_id="f1", future=True, league="NBA"), + self.leg(2, event_id="g2", league="NBA")]) + assert is_eligible([self.leg(1, event_id="f1", future=True, league="NBA"), + self.leg(2, event_id="g2", league="NFL")]) + + +# --------------------------------------------------------------------------- +# The confirm rate +# --------------------------------------------------------------------------- + +class Clock: + def __init__(self, at: float = 1_800_000_000.0): + self.at = at + + def __call__(self) -> float: + return self.at + + +def answered(rate: ConfirmRate, clock: Clock, confirms: int, rejects: int, start: int = 0) -> None: + for i in range(confirms + rejects): + rate.record(f"r{start + i}", i < confirms) + clock.at += 1 + + +class TestConfirmRate: + def test_not_judged_below_twenty(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + answered(rate, clock, 0, 19) + assert not rate.locked() and rate.standing().counted == 19 and not rate.standing().judged + + def test_below_sixty_percent_of_twenty_locks_for_five_minutes(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + answered(rate, clock, 12, 7) + assert rate.standing().rejects_left() == 1 and not rate.would_lock(False) + answered(rate, clock, 0, 1, start=19) + assert not rate.locked() and rate.standing().rate == Decimal("0.6") + assert rate.would_lock(False) and not rate.would_lock(True) and rate.standing().rejects_left() == 0 + answered(rate, clock, 0, 1, start=20) + assert rate.locked() and rate.lock.source == "confirm_rate" + assert rate.lock.until == int((clock.at - 1 + 300) * 1000) + # While locked nothing counts, and counting restarts from zero after. + answered(rate, clock, 0, 5, start=30) + clock.at += 300 + assert not rate.locked() and rate.standing().counted == 0 + + def test_only_the_last_thirty_minutes_count(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + answered(rate, clock, 0, 10) + clock.at += 30 * 60 + 1 + answered(rate, clock, 15, 0, start=10) + assert rate.standing().counted == 15 and rate.standing().rate == 1 + + def test_a_late_confirm_is_amended_to_a_miss(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + rate.record("r1", True) + rate.amend("r1", False) + assert rate.standing().confirmed == 0 and rate.standing().counted == 1 + + def test_the_venues_standing(self): + registration = PricerRegistration.from_row(REST["pricer"]) + standing = registration.standing + assert standing == ConfirmStanding(30, 34, 29, Decimal("0.60"), 20) and registration.lock is None + assert standing.rate == Decimal(29) / Decimal(34) and not standing.below + assert standing.rejects_left() == 14 # 29 / 49 >= 0.6, 29 / 50 < 0.6 + locked = PricerRegistration.from_row(REST["pricer_locked"]) + assert locked.lock == PricerLock(until=ms_of("2026-10-06T18:28:00Z"), source="confirm_rate") + clock = Clock(ms_of("2026-10-06T18:24:10Z") / 1000) + rate = ConfirmRate(clock=clock) + rate.sync(locked.standing, locked.lock) + assert rate.locked() + clock.at = ms_of("2026-10-06T18:28:00Z") / 1000 + assert not rate.locked() + rate.sync(standing) + rate.record("next", False) + assert rate.standing().counted == 35 and rate.standing().confirmed == 29 + + def test_a_lock_from_the_socket_error(self): + lock = lock_of_message(WS["error_locked"]["data"]["message"]) + assert lock == PricerLock(until=ms_of("2026-10-06T18:28:00Z"), source="confirm_rate") + assert lock_of_message(WS["error_rfq_closed"]["data"]["message"]) is None + assert lock_of_message("Maker is locked out of RFQ quoting.") == PricerLock(until=None, source="admin") + + +# --------------------------------------------------------------------------- +# The pricer +# --------------------------------------------------------------------------- + +def live_rfq(rfq_id: str = RFQ_ID, transport: str = "websocket", **changes) -> ParlayRFQ: + data = {**WS["rfq_created"]["data"], "rfq_id": rfq_id, "expires_at": iso(3), **changes} + rfq = ParlayRFQ.from_ws(data) + from dataclasses import replace + return replace(rfq, transport=transport) + + +class TestPricer: + def test_a_round_is_priced_once_across_transports(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + a, b = await asyncio.gather(pricer.quote(live_rfq()), pricer.quote(live_rfq(transport="webhook"))) + assert a == b and a.price == Decimal("0.450") and len(price.calls) == 1 + assert pricer.round_of(RFQ_ID)[1] == a and pricer.stats.quoted == 1 + asyncio.run(go()) + + def test_a_slow_answer_declines(self): + async def go(): + async def slow(rfq): + await asyncio.sleep(1) + return ParlayQuote(Decimal("0.4"), Decimal("100")) + pricer = NovigPricer(slow, credentials=token_creds(), margin=0.25) + assert await pricer.quote(live_rfq(expires_at=iso(0.5))) is None + assert pricer.stats.late == 1 and pricer.stats.declined == 1 + asyncio.run(go()) + + def test_an_expired_round_is_not_priced(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + assert await pricer.quote(ParlayRFQ.from_ws(WS["rfq_created"]["data"])) is None + assert price.calls == [] + asyncio.run(go()) + + def test_a_failing_or_invalid_answer_declines(self): + async def go(): + def boom(rfq): + raise RuntimeError("model down") + pricer = NovigPricer(boom, credentials=token_creds()) + assert await pricer.quote(live_rfq()) is None and pricer.stats.errors == 1 + pricer = NovigPricer(lambda rfq: ParlayQuote(Decimal("1"), Decimal("100")), credentials=token_creds()) + assert await pricer.quote(live_rfq()) is None + asyncio.run(go()) + + def test_below_the_rounds_minimum_declines(self): + async def go(): + pricer = NovigPricer(constant(wager="14"), credentials=token_creds()) + assert await pricer.quote(live_rfq()) is None + pricer = NovigPricer(constant(wager="14"), credentials=token_creds(), decline_below_min_wager=False) + assert (await pricer.quote(live_rfq())).max_wager == Decimal("14") + asyncio.run(go()) + + def test_no_pricing_while_locked_out(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + pricer.confirm_rate.locked_out(until=None, source="admin") + assert await pricer.quote(live_rfq()) is None and price.calls == [] + asyncio.run(go()) + + def test_last_looks(self): + async def go(): + pricer = NovigPricer(constant(price="0.55", wager="250"), credentials=token_creds()) + await pricer.quote(live_rfq()) + assert await pricer.last_look(LastLook.from_ws(WS["quote_accepted"]["data"])) + await pricer.quote(live_rfq("second")) + big = LastLook.from_ws({**WS["quote_accepted"]["data"], "rfq_id": "second", "wager": "251"}) + assert not await pricer.last_look(big) + unknown = LastLook.from_ws({**WS["quote_accepted"]["data"], "rfq_id": "other"}) + assert not await pricer.last_look(unknown) + standing = pricer.confirm_rate.standing() + assert (standing.counted, standing.confirmed) == (3, 1) + assert (pricer.stats.confirmed, pricer.stats.rejected) == (1, 2) + asyncio.run(go()) + + def test_a_confirm_callback_sees_the_quote_and_a_slow_one_rejects(self): + async def go(): + seen = [] + + async def confirm(look): + seen.append(look) + return look.quote is not None and look.collateral < 50 + + pricer = NovigPricer(constant(price="0.55"), confirm=confirm, credentials=token_creds()) + await pricer.quote(live_rfq()) + assert await pricer.last_look(LastLook.from_ws(WS["quote_accepted"]["data"])) + assert seen[0].quote.price == Decimal("0.550") and seen[0].collateral == Decimal("40.90") + + async def slow(look): + await asyncio.sleep(2) + return True + + pricer = NovigPricer(constant(price="0.55"), confirm=slow, credentials=token_creds(), confirm_timeout=0.1) + await pricer.quote(live_rfq()) + assert not await pricer.last_look(LastLook.from_ws(WS["quote_accepted"]["data"])) + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# Webhooks +# --------------------------------------------------------------------------- + +def signed(body: dict) -> tuple[bytes, str]: + raw = json.dumps(body).encode() + return raw, signing.sign_webhook(SECRET, raw) + + +class TestWebhooks: + def body(self, **changes): + return {**HOOK["quote_request"], "timestamp": iso(), **changes} + + def test_a_bad_signature_is_401_and_never_priced(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + raw, _ = signed(self.body()) + assert (await pricer.handle_webhook("quote", raw, "00" * 32))[0] == 401 + assert (await pricer.handle_webhook("quote", raw, None))[0] == 401 + assert price.calls == [] + asyncio.run(go()) + + def test_quote_confirm_and_ping(self): + async def go(): + pricer = NovigPricer(constant(quote_id="pricer-quote-xyz"), credentials=token_creds()) + raw, sig = signed(self.body()) + status, body = await pricer.handle_webhook("quote", raw, sig) + assert status == 200 and body == {"price": 0.45, "max_wager": "250", "quote_id": "pricer-quote-xyz"} + raw, sig = signed(HOOK["confirm_request"]) + assert await pricer.handle_webhook("/novig/confirm", raw, sig) == (200, {"confirmed": True}) + raw, sig = signed(HOOK["ping_request"]) + assert (await pricer.handle_webhook("ping", raw, sig))[0] == 200 + asyncio.run(go()) + + def test_a_decline_is_204(self): + async def go(): + pricer = NovigPricer(lambda rfq: None, credentials=token_creds()) + raw, sig = signed(self.body()) + assert await pricer.handle_webhook("quote", raw, sig) == (204, None) + raw, sig = signed(HOOK["confirm_request"]) + assert await pricer.handle_webhook("confirm", raw, sig) == (200, {"confirmed": False}) + asyncio.run(go()) + + def test_without_a_shared_secret_webhooks_cannot_run(self): + async def go(): + pricer = NovigPricer(constant(), credentials=token_creds(shared_secret=None)) + with pytest.raises(CredentialsMissing): + await pricer.handle_webhook("ping", b"{}", "x") + asyncio.run(go()) + + def test_the_app_under_the_registered_path(self): + from fastapi.testclient import TestClient + + pricer = NovigPricer(constant(quote_id="pricer-quote-xyz"), credentials=token_creds()) + client = TestClient(pricer.webhook_app(prefix="/novig/")) + raw, sig = signed(self.body()) + answer = client.post("/novig/quote", content=raw, headers={"X-Novig-Signature": sig}) + assert answer.status_code == 200 and answer.json()["max_wager"] == "250" + raw, sig = signed(HOOK["confirm_request"]) + assert client.post("/novig/confirm", content=raw, headers={"X-Novig-Signature": sig}).json() == {"confirmed": True} + raw, sig = signed(HOOK["ping_request"]) + assert client.post("/novig/ping", content=raw, headers={"X-Novig-Signature": sig}).status_code == 200 + assert client.post("/novig/ping", content=raw, headers={"X-Novig-Signature": "0" * 64}).status_code == 401 + assert client.post("/novig/quote", content=raw).status_code == 401 + declining = TestClient(NovigPricer(lambda rfq: None, credentials=token_creds()).webhook_app()) + raw, sig = signed(self.body()) + answer = declining.post("/quote", content=raw, headers={"X-Novig-Signature": sig}) + assert answer.status_code == 204 and answer.content == b"" + + +# --------------------------------------------------------------------------- +# The maker WebSocket +# --------------------------------------------------------------------------- + +class FakeSocket: + def __init__(self): + self.inbox: asyncio.Queue = asyncio.Queue() + self.sent: list = [] + self.closed = False + + async def recv(self): + item = await self.inbox.get() + if item is None: + raise ConnectionError("closed") + return item + + async def send(self, text): + self.sent.append(json.loads(text)) + + async def close(self): + self.closed = True + + def push(self, frame): + self.inbox.put_nowait(json.dumps(frame)) + + def events(self, name): + return [m for m in self.sent if m["event"] == name] + + +async def settle_(seconds: float = 0.05): + for _ in range(5): + await asyncio.sleep(0) + await asyncio.sleep(seconds) + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +def socket_pricer(price=None, **kwargs): + socket = FakeSocket() + headers = [] + + async def connect(url, additional_headers=None): + headers.append((url, additional_headers)) + return socket + + pricer = NovigPricer(price or constant(price="0.55"), credentials=token_creds(), **kwargs) + return pricer, NovigPricerStream(pricer, connect=connect), socket, headers + + +class TestPricerStream: + def test_a_round_won_and_confirmed(self): + async def go(): + pricer, stream, socket, headers = socket_pricer() + stream.start() + await settle_() + assert headers == [("wss://api-qa.novig.us/rfq/ws", {"Authorization": "Bearer test-token"})] + assert [m["data"] for m in socket.events("subscribe")] == ["rfq", "quotes"] + socket.push(WS["subscribed"]) + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + assert socket.events("create_quote") == [{"event": "create_quote", "data": { + "rfq_id": RFQ_ID, "price": "0.550", "max_wager": "250", "quote_id": "mm-1"}}] + for name in ("quote_submitted", "rfq_closed", "quote_created", "quote_accepted"): + socket.push(WS[name]) + await settle_() + assert socket.events("confirm") == [{"event": "confirm", "data": { + "rfq_id": RFQ_ID, "quote_id": "0193abcd-0000-7000-8000-00000000000a", "confirmed": True}}] + socket.push(WS["confirm_recorded"]) + socket.push(WS["quote_executed"]) + await settle_() + names = [e.name for e in drain(stream) if isinstance(e, VenueEvent)] + assert names == ["rfq_created", "quote_submitted", "rfq_closed", "quote_created", "quote_accepted", + "confirm_recorded", "quote_executed"] + assert pricer.confirm_rate.standing().confirmed == 1 + await stream.close() + asyncio.run(go()) + + def test_a_declined_round_sends_decline(self): + async def go(): + pricer, stream, socket, _ = socket_pricer(lambda rfq: None) + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + assert socket.events("decline") == [{"event": "decline", "data": {"rfq_id": RFQ_ID}}] + assert socket.events("create_quote") == [] + await stream.close() + asyncio.run(go()) + + def test_a_win_with_the_wrong_terms_is_rejected(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + socket.push(fresh(WS["quote_accepted"], wager="999")) + await settle_() + assert socket.events("confirm")[0]["data"]["confirmed"] is False + await stream.close() + asyncio.run(go()) + + def test_the_auction_closing_stops_pricing(self): + async def go(): + async def slow(rfq): + await asyncio.sleep(5) + return ParlayQuote(Decimal("0.4"), Decimal("100")) + pricer, stream, socket, _ = socket_pricer(slow) + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + socket.push(WS["rfq_closed"]) + await settle_() + assert socket.events("create_quote") == [] and socket.events("decline") == [] + assert stream._pricing == {} + await stream.close() + asyncio.run(go()) + + def test_a_confirm_rate_lock_stops_quoting(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.start() + await settle_() + future = (datetime.now(timezone.utc) + timedelta(minutes=4)).strftime("%Y-%m-%dT%H:%M:%SZ") + socket.push(fresh(WS["error_locked"], + message=f"Maker is locked out of RFQ quoting until {future} for a low confirm rate.")) + await settle_() + assert pricer.confirm_rate.locked() and pricer.confirm_rate.lock.source == "confirm_rate" + errors = [e for e in drain(stream) if isinstance(e, StreamStatusEvent) and e.state == "error"] + assert errors and "FORBIDDEN" in errors[0].detail + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + assert socket.events("create_quote") == [] and socket.events("decline") == [] + await stream.close() + asyncio.run(go()) + + def test_a_confirm_too_late_counts_as_a_miss(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + socket.push(WS["quote_accepted"]) + await settle_() + assert pricer.confirm_rate.standing().confirmed == 1 + socket.push({"event": "error", "data": {"code": "NO_CONFIRM_WINDOW", "message": "late", + "rfq_id": RFQ_ID}, "timestamp": iso()}) + await settle_() + assert pricer.confirm_rate.standing().confirmed == 0 + await stream.close() + asyncio.run(go()) + + def test_a_refused_upgrade_is_final(self): + async def go(): + class Refused(Exception): + def __init__(self, code): + self.response = type("R", (), {"status_code": code})() + + async def connect(url, additional_headers=None): + raise Refused(403) + + pricer = NovigPricer(constant(), credentials=token_creds()) + stream = NovigPricerStream(pricer, connect=connect) + events = [e async for e in stream] + assert events[-1].state == "failed" and "register" in events[-1].detail.lower() + asyncio.run(go()) + + def test_a_reconnect_asks_for_reconciliation(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.backoff = lambda attempt: 0.0 + stream.start() + await settle_() + socket.inbox.put_nowait(None) + await settle_() + connected = [e for e in drain(stream) if isinstance(e, StreamStatusEvent) and e.state == "connected"] + assert len(connected) == 2 and connected[1].reconcile_required + await stream.close() + asyncio.run(go()) + + +class TestExecutionFeed: + def test_offsets_and_gaps(self): + async def go(): + socket = FakeSocket() + + async def connect(url, additional_headers=None): + assert url == "wss://api-qa.novig.us/rfq/executions/feed" + return socket + + feed = NovigExecutionFeed(token_creds(), connect=connect) + feed.start() + await settle_() + frame = REST["feed_frame"] + socket.push(frame) + socket.push({**frame, "data": {**frame["data"], "offset": 12346}}) + socket.push({**frame, "data": {**frame["data"], "offset": 12349}}) + await settle_() + events = drain(feed) + assert [e.payload["offset"] for e in events if isinstance(e, VenueEvent)] == [12345, 12346, 12349] + gaps = [e for e in events if isinstance(e, StreamStatusEvent) and e.state == "gap"] + assert len(gaps) == 1 and gaps[0].reconcile_required and "12347 to 12348" in gaps[0].detail + await feed.close() + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# REST, against a scripted venue +# --------------------------------------------------------------------------- + +class Venue: + def __init__(self): + self.requests: list[httpx.Request] = [] + self.executions = [{**REST["executions"][0], "rfq_id": f"00000000-0000-7000-8000-{n:012d}"} for n in range(7, 0, -1)] + self.tokens = 0 + self.reject_token: str | None = None + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + if path == "/nbx/v1/auth/emm-token": + self.tokens += 1 + body = json.loads(request.content) + assert body == {"grant_type": "client_credentials", "client_id": "cid", "client_secret": "csecret"} + return httpx.Response(200, json={"access_token": f"token-{self.tokens}", "expires_in": 1800}) + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer ") or auth == f"Bearer {self.reject_token}": + return httpx.Response(401, json={"message": "invalid token"}) + if path == "/rfq/pricer" and request.method == "GET": + return httpx.Response(200, json=REST["pricer"]) + if path == "/rfq/executions": + limit = int(request.url.params.get("limit", 500)) + after = request.url.params.get("after") + rows = self.executions + if after: + ids = [r["rfq_id"] for r in rows] + rows = rows[ids.index(after) + 1:] if after in ids else [] + return httpx.Response(200, json=rows[:limit]) + if path == "/rfq/collateral": + return httpx.Response(200, json=REST["collateral"]) + return httpx.Response(404, json={"message": "not found"}) + + +def rest(venue: Venue, **creds) -> NovigRFQ: + credentials = NovigRFQCredentials(**({"client_id": "cid", "client_secret": "csecret", "env": "qa"} | creds)) + transport = httpx.MockTransport(venue) + return NovigRFQ(credentials, client=httpx.AsyncClient(transport=transport), + token_client=httpx.AsyncClient(transport=transport)) + + +class TestREST: + def test_the_token_is_exchanged_once_and_sent_as_a_bearer(self): + async def go(): + venue = Venue() + async with rest(venue) as rfq: + registration = await rfq.fetch_pricer() + collateral = await rfq.fetch_collateral() + assert venue.tokens == 1 + assert venue.requests[0].url == "https://api-qa.novig.us/nbx/v1/auth/emm-token" + assert all(r.headers["Authorization"] == "Bearer token-1" for r in venue.requests[1:]) + assert registration.pricer_id == REST["pricer"]["pricerId"] and registration.standing.counted == 34 + assert collateral == RFQCollateral(Decimal("300.00"), 2, ms_of("2026-09-23T21:36:32.123456Z"), + REST["collateral"]) + asyncio.run(go()) + + def test_a_refused_token_is_renewed_once(self): + async def go(): + venue = Venue() + venue.reject_token = "token-1" + async with rest(venue) as rfq: + assert (await rfq.fetch_pricer()) is not None + assert venue.tokens == 2 + asyncio.run(go()) + + def test_no_pricer_is_none_and_403_names_registration(self): + async def go(): + def answer(request): + if request.url.path == "/rfq/pricer": + return httpx.Response(404, json={"message": "no pricer"}) + return httpx.Response(403, json={"message": "forbidden"}) + client = httpx.AsyncClient(transport=httpx.MockTransport(answer)) + async with NovigRFQ(token_creds(), client=client) as rfq: + assert await rfq.fetch_pricer() is None + with pytest.raises(PermissionDenied, match="registered pricer"): + await rfq.fetch_collateral() + asyncio.run(go()) + + def test_executions_page_and_reconcile(self): + async def go(): + venue = Venue() + async with rest(venue) as rfq: + page = await rfq.fetch_executions(limit=3) + every = [row async for row in rfq.iter_executions(page=3)] + known = {venue.executions[4]["rfq_id"], venue.executions[5]["rfq_id"]} + missing = await rfq.reconcile_executions(known, page=2) + settled = await rfq.fetch_executions(status="settled", limit=2) + assert [r.rfq_id for r in page] == [r["rfq_id"] for r in venue.executions[:3]] + assert [r.rfq_id for r in every] == [r["rfq_id"] for r in venue.executions] + assert [r.rfq_id for r in missing] == [r["rfq_id"] for r in venue.executions[:4]] + sent = [r for r in venue.requests if r.url.path == "/rfq/executions"] + assert sent[1].url.params.get("after") is None and sent[2].url.params["after"] == venue.executions[2]["rfq_id"] + assert sent[-1].url.params["status"] == "settled" and len(settled) == 2 + with pytest.raises(ValueError): + await rfq.fetch_executions(limit=501) + asyncio.run(go()) + + def test_registration_and_ping_send_what_the_docs_show(self): + async def go(): + seen = [] + + def answer(request): + seen.append(request) + if request.url.path == "/rfq/pricer/ping": + return httpx.Response(200, json=REST["ping"]) + return httpx.Response(200, json={**REST["register_response"], "sharedSecret": SECRET}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(answer)) + async with NovigRFQ(token_creds(), client=client) as rfq: + registration = await rfq.register_pricer("https://pricer.example.com/novig") + socket_only = await rfq.register_pricer() + ping = await rfq.ping_webhook() + assert json.loads(seen[0].content) == {"webhookUrl": "https://pricer.example.com/novig"} + assert json.loads(seen[1].content) == {} + assert registration.shared_secret == SECRET and "sharedSecret" not in registration.info + assert SECRET not in repr(registration) and socket_only.pricer_id + assert ping["ok"] is True and seen[2].method == "POST" + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# Credentials +# --------------------------------------------------------------------------- + +class TestCredentials: + def test_from_the_environment(self, tmp_path): + env = {"NOVIG_RFQ_CLIENT_ID": "cid", "NOVIG_RFQ_CLIENT_SECRET": "csecret", + "NOVIG_RFQ_SHARED_SECRET": SECRET.upper(), "NOVIG_RFQ_ENV": "QA"} + creds = load_novig_rfq(env, dotenv=tmp_path / "none.env", redact_logs=False) + assert creds.client_id == "cid" and creds.shared_secret == SECRET and creds.env == "qa" + assert "csecret" not in repr(creds) and SECRET not in repr(creds) + docs_name = load_novig_rfq({"NOVIG_RFQ_SECRET": SECRET}, dotenv=tmp_path / "none.env", redact_logs=False) + assert docs_name.shared_secret == SECRET and docs_name.env == "production" + assert load_novig_rfq({}, dotenv=tmp_path / "none.env") is None + + @pytest.mark.parametrize("env", [ + {"NOVIG_RFQ_CLIENT_ID": "cid"}, + {"NOVIG_RFQ_ACCESS_TOKEN": "t", "NOVIG_RFQ_SHARED_SECRET": "short"}, + {"NOVIG_RFQ_ACCESS_TOKEN": "t", "NOVIG_RFQ_ENV": "paper"}, + ]) + def test_mistakes_are_named(self, env, tmp_path): + with pytest.raises(CredentialsMissing): + load_novig_rfq(env, dotenv=tmp_path / "none.env", redact_logs=False) + + def test_no_token_source_is_named_on_first_use(self): + async def go(): + async with NovigRFQ(NovigRFQCredentials(env="qa")) as rfq: + with pytest.raises(CredentialsMissing, match="NOVIG_RFQ_CLIENT_ID"): + await rfq.token.get() + asyncio.run(go()) From 47a7cca94622bbebf49bff2103d99642499044c3 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 17/19] feat: azuro-combos-claim Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 7 +- docs/capabilities.md | 9 +- docs/trading.md | 71 +- synpath/azuro.py | 16 +- synpath/trading/azuro.py | 631 ++++++++++++++-- synpath/trading/azuro_signing.py | 166 +++- tests/samples/azuro_redeemable_bets.json | 923 +++++++++++++++++++++++ tests/samples/azuro_signing_vectors.json | 232 +++++- tests/test_azuro.py | 11 + tests/test_azuro_trading.py | 464 +++++++++++- 10 files changed, 2450 insertions(+), 80 deletions(-) create mode 100644 tests/samples/azuro_redeemable_bets.json diff --git a/docs/api.md b/docs/api.md index 4de4820..03db898 100644 --- a/docs/api.md +++ b/docs/api.md @@ -176,9 +176,11 @@ Azuro needs no key. It adds `fetch_sports()` (the feed's sports, countries and leagues with their open game counts), `fetch_limits(market_id, side)` (the pool's minimum and maximum bet and maximum payout on one selection) and `fetch_condition_states(condition_ids)` (fresh odds and states, without the -games), and takes `sport` and `league` (the feed's slugs, `"football"`, +games), `fetch_conditions(condition_ids)` (conditions by id with their game +and whether they may go in a combo, `isExpressForbidden`), and takes `sport` and `league` (the feed's slugs, `"football"`, `"premier-league"`) on `fetch_markets` and `fetch_events`. `chain=` picks the -pool read: `polygon` (the default), `base`, or a testnet. See +pool read: `polygon` (the default), `base`, or a testnet. Combos and claiming +payouts are on `AzuroTrading` ([trading](trading.md#azuro)). See [Azuro](capabilities.md#azuro). ```python @@ -188,6 +190,7 @@ market = markets[0] market.yes.quote.ask, market.info["odds"] # 1 / odds, and the venue's odds azuro.fetch_limits(market.id) # {"min_bet", "max_bet", "max_payout", ...} azuro.fetch_trades(market.id) # single bets, from the bets subgraph +azuro.fetch_conditions([market.info["condition_id"]]) # {id: condition}, isExpressForbidden and the game ``` Myriad needs no key (an API key and secret raise its rate limit). It takes diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..2f58c78 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -377,7 +377,8 @@ returns the feed's sports, countries and leagues with their open game counts. The tape is every single bet on a condition, from Azuro's bets subgraph (`thegraph.onchainfeed.org`), in the YES price with the bettor's side; combo -bets are left out. There is no fee on top of the odds (`pool_margin`); a bet +bets are left out (`AzuroTrading` places and reads combos; see +[trading](trading.md#azuro)). There is no fee on top of the odds (`pool_margin`); a bet placed through the relayer pays it a flat fee per bet in the bet token, which `AzuroTrading.fetch_fee_estimate` reads. @@ -535,11 +536,11 @@ onboarded firms. | `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | | `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | | `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES, and settled ones still to claim as `redeemable` (`redeem()` claims them); the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets and combos: won, lost or cancelled, each with whether its payout is claimed | no | yes | | `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | | `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no; a combo needs no quote: the pool prices it from its legs (`fetch_combo_quote`, `create_combo_order`) | no | no: combo RFQ is a WebSocket auction for market makers, in beta | | `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..dc52d64 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -105,7 +105,7 @@ The variables each venue reads: | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | | ProphetX | `PROPHETX_ACCESS_KEY` and `PROPHETX_SECRET_KEY` (the Trading API: market data, streams, orders), `PROPHETX_AFFILIATE_KEY` (optional; the Market Data API, market data only), `PROPHETX_SANDBOX=1` (optional; ProphetX's sandbox, with demo funds) | -| Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance and allowance are read from and the approval is sent through; a public one when unset) | +| Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance, allowance and claimable payouts are read from and the approval and claims are sent through; a public one when unset) | | Gemini | `GEMINI_API_KEY` (`account-...`), `GEMINI_API_SECRET`, `GEMINI_ACCOUNT` (optional; the subaccount a `master-...` key acts for, REST only: the WebSocket refuses master keys), `GEMINI_ENV` = `prod` (the default, real money) or `sandbox` (api.sandbox.gemini.com, with a sandbox key) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, @@ -723,9 +723,9 @@ Check ProphetX's terms for where it may be used before trading. ## Azuro -`AzuroTrading`, built to `@azuro-org/toolkit` and the Azuro backend API. Bet -and cash-out digests and signatures are checked against the ones the toolkit -and viem produce. +`AzuroTrading`, built to `@azuro-org/toolkit` and the Azuro backend API. Bet, +combo and cash-out digests and signatures, and the claim calldata, are checked +against the ones the toolkit and viem produce. The one credential is the wallet. Every bet is the EIP-712 message `ClientBetData` (the consent text, the affiliate, the pool's core contract, a @@ -760,13 +760,64 @@ an accepted one can be closed early where the venue offers a price: the domain `Cash Out` `1.0.0`, the offer's odds as the minimum) and sends it, and `fetch_cashout(id)` follows it. +A combo backs two or more outcomes of different games in one bet, which wins +only if every leg does. Nobody quotes it: the pool prices it from the legs' +odds by the protocol's combo rule, each leg's odds divided by 0.99 (the +feed's fee) and rounded up to two decimals, multiplied, and times 0.99 once +(1.58 and 1.58 make 2.5344, as the bets subgraph records it). A leg is a +market and a side, as an order names them (`ComboLeg(market_id, side)`, a +bare market id for its YES, or a `(market_id, side)` pair). + +```python +from synpath.trading.azuro import ComboLeg + +legs = [ComboLeg("azuro:"), ComboLeg("azuro:", Side.SELL)] +quote = await azuro.fetch_combo_quote(legs, stake=Decimal("5")) +quote.odds, quote.price, quote.max_bet, quote.problems # [] when the pool would take it +order = await azuro.create_combo_order(legs, quantity=Decimal("10"), price=quote.price * Decimal("1.02")) +await azuro.wait_for_order(order.id) +``` + +`fetch_combo_quote` reads the legs' conditions and the pool's limits on the +combo for this wallet (`/bet/calculation`), and lists in `problems` what the +venue's SDK checks before it signs one: two or more legs, each condition and +each game once, no condition closed to combos (`isExpressForbidden`), every +condition and outcome taking bets, a maximum stake above zero and the stake +within the limits. There is no maximum number of legs in the toolkit or the +SDK; the pool refuses what it will not take. `create_combo_order` is a market +order with a worst price for the whole combo: `quantity` contracts (what it +pays if every leg wins) at no more than `price` each, so minimum total odds +`1 / price` and a stake of `quantity * price`. It raises the quote's problems +before signing (`check=False` skips the quote), signs `ClientComboBetData` +(the same `ClientData`, the minimum odds, the stake, the nonce and the legs' +`ComboPart`s, under the same domain), and sends it to `/bet/orders/combo`. +The relayer's fee is the same flat fee per bet. A combo order is followed like +a single bet, and reported on its first leg's market as a `buy` at `1 /` its +total odds, with `info["combo"]` and every leg in `info["legs"]`. + `fetch_orders`, `fetch_open_orders` (bets not yet on chain), `fetch_my_trades` -(one fill per accepted bet) and `fetch_settlements` (won, lost, or cancelled -with the stake returned) read the wallet's bet orders. Positions net each -market's accepted, unsettled single bets on the YES leg. Combo bets are read -on their first leg's market and marked `info["combo"]`; this library places -single bets only. A winning bet's payout is claimed on chain from the pool, -which this library does not do. +(one fill per accepted single bet) and `fetch_settlements` (won, lost, or +cancelled with the stake returned; combos too, on their first leg's market) +read the wallet's bet orders. Each settlement says whether its payout was +claimed: `info["claimed"]` (`None` when it pays nothing), `info["claimable"]` +(what is still in the pool) and `info["claim_transaction"]`. Positions net +each market's accepted, unsettled single bets on the YES leg, and add the +settled single bets whose payout is still to be claimed, `resolved` and +`final`, with the amount in `redeemable`. + +A won or cancelled bet's payout stays in the pool until it is claimed on +chain. `fetch_redeemable_bets()` lists the wallet's bets that can be, from +the bets subgraph (won or cancelled, not cashed out, not yet claimed), each +with its on-chain id, `won` or `canceled`, and the `amount` claiming pays. +`redeem()` (also `claim_winnings()`) claims all of them, or those of +`bet_ids`, as the venue's SDK does: one `withdrawPayouts(core, betIds)` +transaction to the pool's liquidity contract for every fifty bets +(`batch=`), one paymaster `withdrawPayouts` per free bet. The wallet sends +them and pays their gas in the chain's coin; anyone may send a claim, and the +pool pays each bet's owner. Every call is estimated first, so one that would +revert (already claimed: `alreadypaid`) is refused before anything is sent. +It returns each transaction's hash, bets and amount, and the total. +`fetch_claimable(bet_id)` reads what one bet would pay now (`viewPayout`). Gnosis and Chiliz are discontinued and take no bets. Azuro's bet API answers some regions with a 503; check where Azuro may be used before betting. diff --git a/synpath/azuro.py b/synpath/azuro.py index c13caa3..72171bb 100644 --- a/synpath/azuro.py +++ b/synpath/azuro.py @@ -115,7 +115,7 @@ def subgraph_url(self) -> str: core="0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", relayer="0x8dA05c0021e6b35865FDC959c54dCeF3A4AbBa9d", lp="0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", cashout="0x4a2BB4211cCF9b9eA6eF01D0a61448154ED19095", token="0xc2132d05d31c914a87c6611c10748aeb04b58e8f", symbol="USDT", decimals=6, - rpc_url="https://polygon-rpc.com", + rpc_url="https://polygon-bor-rpc.publicnode.com", ), "base": Chain( "base", 8453, "BaseWETH", "base", @@ -690,6 +690,20 @@ def fetch_condition_states(self, condition_ids: list[str]) -> list[dict[str, Any rows = (data or {}).get("conditions") or [] if isinstance(data, dict) else [] return [r for r in rows if isinstance(r, dict) and r.get("conditionId")] + def fetch_conditions(self, condition_ids: list[str]) -> dict[str, dict[str, Any]]: + """Many conditions by id, each as its game's condition listing has it + (`/market-manager/conditions-by-game-ids`): state, outcomes and odds, + the game, and `isExpressForbidden`. A condition whose id names no game + is read from the condition batch, without those last two.""" + wanted = list(dict.fromkeys(str(c) for c in condition_ids)) + games = list(dict.fromkeys(g for g in (game_of_condition(c) for c in wanted) if g)) + found = {str(row["conditionId"]): row for rows in self._conditions(games).values() for row in rows + if str(row["conditionId"]) in wanted} + missing = [c for c in wanted if c not in found] + if missing: + found.update({str(row["conditionId"]): row for row in self.fetch_condition_states(missing)}) + return {c: found[c] for c in wanted if c in found} + def condition_outcomes(self, condition_ids: list[str]) -> dict[str, list[str]]: """Each condition's outcome ids, lowest first: what decides its markets' ids and which outcome is YES.""" diff --git a/synpath/trading/azuro.py b/synpath/trading/azuro.py index a9c532d..14a1543 100644 --- a/synpath/trading/azuro.py +++ b/synpath/trading/azuro.py @@ -29,21 +29,33 @@ but where the venue offers it a bet can be cashed out before it settles (`fetch_cashout_quote`, `cash_out`). +**A combo** backs two or more outcomes of different games in one bet, which +wins only if every leg does. It is priced by the pool, not quoted by anyone: +its odds are the legs' odds by the protocol's combo rule (`azuro_signing.combo_odds`). +`fetch_combo_quote` reads the legs' odds, states and the pool's limits on the +combo and says what stops it; `create_combo_order` signs it with a worst price +for the whole combo (minimum total odds `1 / price`) and sends it to +`/bet/orders/combo`. It is then followed like a single bet. + **Positions** net a market's accepted, unsettled single bets on the YES leg: -the contracts on the YES outcome less those on the other. A winning bet's -payout is claimed on chain from the pool; this library does not send that +the contracts on the YES outcome less those on the other. Settled single bets +whose payout is still to be claimed are positions too, `resolved` with the +amount waiting in `redeemable`. A won or cancelled bet's payout stays in the +pool until it is claimed on chain: `fetch_redeemable_bets` lists them and +`redeem` sends the claims from the wallet, `CLAIM_BATCH` bets of one pool a transaction. """ from __future__ import annotations import asyncio import time +from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal from typing import Any from .. import ids -from ..azuro import Azuro, Chain, chain_of, game_of_condition, parse_market_id +from ..azuro import Azuro, Chain, chain_of, game_of_condition, odds_value, outcome_odds, parse_market_id from ..base import AsyncHttpClient, Capability from ..errors import BadRequest, ExchangeError, MarketNotFound, NotSupported from ..types import Page @@ -84,6 +96,32 @@ ORDERS_PAGE = 100 MAX_ORDER_PAGES = 50 +MIN_COMBO_LEGS = 2 +"""A combo is two or more legs. The toolkit and the SDK set no upper bound; +the pool refuses what it will not take (accepted combos of 33 legs exist).""" + +CLAIM_BATCH = 50 +"""Bets claimed in one `withdrawPayouts` transaction. The contract takes any +number; this bounds each transaction's gas.""" + +SUBGRAPH_PAGE = 100 +MAX_SUBGRAPH_PAGES = 20 + +REDEEMABLE_QUERY = """query Redeemable($first: Int!, $skip: Int!, $where: V3_Bet_filter!) { + v3Bets(first: $first, skip: $skip, where: $where, orderBy: createdBlockNumber, orderDirection: desc, + subgraphError: allow) { + id tokenId: betId actor type status result amount rawAmount odds settledOdds + potentialPayout rawPotentialPayout payout rawPayout + isRedeemed isRedeemable isCashedOut redeemedTxHash + freebetId isFreebetAmountReturnable paymasterContractAddress + createdAt: createdBlockTimestamp resolvedAt: resolvedBlockTimestamp txHash: createdTxHash + core { address liquidityPool { address tokenDecimals } } + selections { odds result outcome { outcomeId condition { conditionId gameId status } } } + } +}""" +"""The SDK's `Bet` fragment, trimmed, on the bets subgraph; with the toolkit's +`Unredeemed` filter (`isRedeemable`, not cashed out) it lists what can be claimed.""" + def D(value: Any) -> Decimal: if value in (None, ""): @@ -127,6 +165,37 @@ def _leg(row: dict[str, Any]) -> dict[str, Any]: return legs[0] if legs and isinstance(legs[0], dict) else {} +def _legs(row: dict[str, Any]) -> list[dict[str, Any]]: + return [leg for leg in row.get("conditions") or [] if isinstance(leg, dict)] + + +def is_combo(row: dict[str, Any]) -> bool: + return row.get("betType") == "COMBO" + + +def combo_legs_of(row: dict[str, Any], outcomes: dict[str, list[str]]) -> list[dict[str, Any]]: + """A combo's legs: each one's market and side there, with the leg's odds + and result as the venue records them.""" + out = [] + for leg in _legs(row): + condition_id, outcome_id = str(leg.get("conditionId") or ""), str(leg.get("outcomeId") or "") + market_id, side = market_of(condition_id, outcome_id, outcomes.get(condition_id)) + out.append({"market_id": market_id, "side": side, "condition_id": condition_id, "outcome_id": outcome_id, + "odds": D(leg.get("price")) if leg.get("price") not in (None, "") else None, + "result": leg.get("result")}) + return out + + +def claim_of_row(row: dict[str, Any], payout: Decimal) -> dict[str, Any]: + """Whether a settled bet's payout is claimed, and what is left to claim: + a won or cancelled bet, not cashed out, pays from the pool once claimed.""" + meta = row.get("meta") if isinstance(row.get("meta"), dict) else {} + claimed = bool(row.get("redeemedAt") or meta.get("isRedeemed")) + payable = row.get("result") in ("Won", "Canceled") and not meta.get("isCashedOut") and payout > 0 + return {"claimed": claimed if payable else None, "claimable": payout if payable and not claimed else Decimal("0"), + "claim_transaction": meta.get("redeemedTxHash")} + + def contracts_of_row(row: dict[str, Any]) -> Decimal: """What the bet pays if it wins: its stake times its odds.""" leg = _leg(row) @@ -135,10 +204,12 @@ def contracts_of_row(row: dict[str, Any]) -> Decimal: return D(row.get("amount")) * D(row.get("odds")) -def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None) -> Order: +def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None, + legs_outcomes: dict[str, list[str]] | None = None) -> Order: """One bet order (`GET /bet/orders/{id}`, `GET /bet/orders/bettor/...`), - as an order on the YES leg. A combo is reported on its first leg's market, - marked `info["combo"]`.""" + as an order on the YES leg. A combo is reported on its first leg's market + as a buy at `1 / ` its total odds, marked `info["combo"]`, with every + leg's market and side in `info["legs"]` (`legs_outcomes` names them).""" leg = _leg(row) market_id, side = market_of(str(leg.get("conditionId") or ""), str(leg.get("outcomeId") or ""), outcomes) state = str(row.get("state") or "") @@ -146,6 +217,11 @@ def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Ac odds = D(row.get("odds") or leg.get("price")) quantity = contracts_of_row(row) filled = quantity if state in FILLED_STATES else Decimal("0") + extra: dict[str, Any] = {"combo": is_combo(row)} + if is_combo(row): + side = Side.BUY + extra["legs"] = combo_legs_of(row, {**(legs_outcomes or {}), + str(leg.get("conditionId") or ""): outcomes or []}) price = yes_price(odds, side) return Order( id=str(row.get("id") or ""), @@ -164,7 +240,7 @@ def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Ac cost=D(row.get("amount")), created_at=iso_ms(row.get("createdAt")), updated_at=iso_ms(row.get("updatedAt")), - info={**row, "combo": row.get("betType") == "COMBO"}, + info={**row, **extra}, ) @@ -189,12 +265,16 @@ def fill_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Acc ) -def settlement_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None) -> Settlement | None: - """A settled single bet: won, lost, or cancelled (the stake returned).""" +def settlement_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None, + legs_outcomes: dict[str, list[str]] | None = None) -> Settlement | None: + """A settled bet: won, lost, or cancelled (the stake returned), with + `info["claimed"]` (whether a payout was claimed; `None` when there is + none) and `info["claimable"]` (what is still in the pool to claim). A + combo is reported on its first leg's market, marked `info["combo"]`.""" result = row.get("result") - if row.get("betType") == "COMBO" or result in (None, ""): + if result in (None, ""): return None - order = order_of_row(row, outcomes=outcomes, account=account) + order = order_of_row(row, outcomes=outcomes, account=account, legs_outcomes=legs_outcomes) cost = D(row.get("amount")) payout = D(row.get("payout")) if row.get("payout") is not None else (order.quantity if result == "Won" else cost if result == "Canceled" else Decimal("0")) @@ -204,7 +284,8 @@ def settlement_of_row(row: dict[str, Any], *, outcomes: list[str] | None, accoun result=str(result).lower(), won=result == "Won" if result != "Canceled" else None, quantity=order.quantity, cost=cost, payout=payout, pnl=payout - cost, timestamp=iso_ms(row.get("settledAt")) or iso_ms(row.get("updatedAt")), - info=row, + info={**row, "combo": order.info["combo"], **({"legs": order.info["legs"]} if "legs" in order.info else {}), + **claim_of_row(row, payout)}, ) @@ -233,6 +314,117 @@ def positions_of(rows: list[dict[str, Any]], *, outcomes: dict[str, list[str]], return out +# --------------------------------------------------------------------------- +# Redeemable bets +# --------------------------------------------------------------------------- + +def freebet_share(payout: int, amount: int, returnable: bool | None) -> int: + """The bettor's part of a free bet's payout (the toolkit's + `calcFreebetBettorShare`): nothing unless it beat the stake, then the + payout less the stake, or all of it when the stake is not returnable.""" + if payout <= amount: + return 0 + return payout if returnable is False else payout - amount + + +def redeemable_of_bet(bet: dict[str, Any]) -> dict[str, Any] | None: + """One bet from the bets subgraph as a claim: its on-chain id, whether it + `won` or was `canceled`, the `amount` claiming pays the wallet, and the + contracts to claim from. `None` for a bet with nothing to claim.""" + won = bet.get("result") == "Won" + canceled = bet.get("status") == "Canceled" + if not bet.get("isRedeemable") or bet.get("isCashedOut") or not (won or canceled): + return None + core = bet.get("core") or {} + pool = core.get("liquidityPool") or {} + decimals = int(pool.get("tokenDecimals") or 0) + stake = int(D(bet.get("rawAmount"))) + raw = int(D(bet["rawPayout"])) if bet.get("rawPayout") not in (None, "") else \ + int(D(bet.get("rawPotentialPayout"))) if won else stake + freebet = str(bet.get("freebetId") or "") or None + if freebet: + raw = freebet_share(raw, stake, bet.get("isFreebetAmountReturnable")) + return { + "bet_id": str(bet.get("tokenId") or bet.get("betId")), + "combo": bet.get("type") == "Express", + "result": "won" if won else "canceled", + "amount": sig.token_amount(raw, decimals), + "raw_amount": raw, + "stake": sig.token_amount(stake, decimals), + "core": str(core.get("address") or ""), + "lp": str(pool.get("address") or ""), + "freebet_id": freebet, + "paymaster": bet.get("paymasterContractAddress"), + "legs": [(str(((sel.get("outcome") or {}).get("condition") or {}).get("conditionId") or ""), + str((sel.get("outcome") or {}).get("outcomeId") or "")) for sel in bet.get("selections") or []], + "info": bet, + } + + +def claim_calls(bets: list[dict[str, Any]], *, chain: Chain, batch: int = CLAIM_BATCH) -> list[dict[str, Any]]: + """The transactions that claim these bets (`redeemable_of_bet`), as the + SDK's `useRedeemBet` builds them: one `withdrawPayouts(core, betIds)` to + the pool's liquidity contract per `batch` bets of one core; one + `withdrawPayout(core, betId)` each on a legacy pool; one paymaster + `withdrawPayouts([freeBetId])` per free bet. Each is `{"to", "data", + "bet_ids", "raw_amount"}`.""" + if batch < 1: + raise InvalidOrder(f"{VENUE}: a claim batch holds at least one bet") + calls: list[dict[str, Any]] = [] + pools: dict[tuple[str, str], list[dict[str, Any]]] = {} + for bet in bets: + if bet.get("freebet_id"): + if not bet.get("paymaster"): + raise InvalidOrder(f"{VENUE}: free bet {bet['bet_id']} names no paymaster to claim from") + calls.append({"to": bet["paymaster"], "data": sig.paymaster_withdraw_payouts_calldata([bet["freebet_id"]]), + "bet_ids": [bet["bet_id"]], "raw_amount": bet["raw_amount"]}) + continue + pools.setdefault((bet["lp"].lower(), bet["core"].lower()), []).append(bet) + for (lp, core), group in pools.items(): + if lp != chain.lp.lower(): + calls += [{"to": b["lp"], "data": sig.withdraw_payout_calldata(b["core"], b["bet_id"]), + "bet_ids": [b["bet_id"]], "raw_amount": b["raw_amount"]} for b in group] + continue + for start in range(0, len(group), batch): + chunk = group[start:start + batch] + calls.append({"to": chain.lp, "data": sig.withdraw_payouts_calldata(core, [b["bet_id"] for b in chunk]), + "bet_ids": [b["bet_id"] for b in chunk], "raw_amount": sum(b["raw_amount"] for b in chunk)}) + return calls + + +def claimable_positions_of(bets: list[dict[str, Any]], *, outcomes: dict[str, list[str]], decimals: int, + account: Account | None = None) -> list[Position]: + """Settled single bets waiting to be claimed, per market: `resolved`, + `won` (`None` when cancelled), and the amount waiting in `redeemable`. + Combos have no one market and are left to `fetch_redeemable_bets`.""" + nets: dict[str, dict[str, Any]] = {} + for bet in bets: + if bet["combo"] or not bet["legs"]: + continue + condition_id, outcome_id = bet["legs"][0] + market_id, side = market_of(condition_id, outcome_id, outcomes.get(condition_id)) + entry = nets.setdefault(market_id, {"yes": Decimal("0"), "no": Decimal("0"), "cost": Decimal("0"), + "amount": Decimal("0"), "won": set(), "bets": []}) + info = bet["info"] + entry["yes" if side == Side.BUY else "no"] += sig.token_amount(int(D(info.get("rawPotentialPayout"))), decimals) + entry["cost"] += bet["stake"] + entry["amount"] += bet["amount"] + entry["won"].add(bet["result"] == "won") + entry["bets"].append(bet["bet_id"]) + out = [] + for market_id, entry in nets.items(): + net = entry["yes"] - entry["no"] + out.append(Position( + venue=VENUE, account=account, market_id=market_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), inventory_yes=entry["yes"], inventory_no=entry["no"], margin=entry["cost"], + resolved=True, final=True, won=True if entry["won"] == {True} else None, + payout=entry["amount"], redeemable=entry["amount"], + info={"bets": "settled single bets not yet claimed", "bet_ids": entry["bets"]}, + )) + return out + + def error_of(exc: ExchangeError) -> ExchangeError: body = exc.body if isinstance(exc.body, dict) else {} text = str(body.get("errorMessage") or body.get("message") or body.get("error") or exc) @@ -267,24 +459,28 @@ def check_request(request: OrderRequest) -> None: raise InvalidOrder(f"{VENUE}: bets are not netted at the venue; there is no reduce-only bet") -def selection_of(request: OrderRequest, outcomes: list[str]) -> tuple[str, str, Decimal]: - """(condition id, outcome id, price paid for the outcome) for a request.""" - condition_id, outcome_id = parse_market_id(ids.native(VENUE, request.market_id)) - price = D(request.price) +def outcome_of(market_id: str, side: Side, outcomes: list[str]) -> tuple[str, str]: + """(condition id, outcome id) a side of a market backs.""" + condition_id, outcome_id = parse_market_id(ids.native(VENUE, market_id)) if outcome_id is not None: if len(outcomes) == 2: raise InvalidOrder(f"{VENUE}: {condition_id} has two outcomes and is one market, {VENUE}:{condition_id}") if outcomes and outcome_id not in outcomes: raise InvalidOrder(f"{VENUE}: condition {condition_id} has no outcome {outcome_id}") - if request.side == Side.SELL: + if side == Side.SELL: raise InvalidOrder(f"{VENUE}: one outcome of a larger condition has only YES to buy; the pool sells no NO") - return condition_id, outcome_id, price + return condition_id, outcome_id if len(outcomes) != 2: raise InvalidOrder(f"{VENUE}: condition {condition_id} has {len(outcomes)} outcomes; name one: " f"{', '.join(f'{VENUE}:{condition_id}-{o}' for o in outcomes)}") - if request.side == Side.BUY: - return condition_id, outcomes[0], price - return condition_id, outcomes[1], Decimal(1) - price + return condition_id, outcomes[0] if side == Side.BUY else outcomes[1] + + +def selection_of(request: OrderRequest, outcomes: list[str]) -> tuple[str, str, Decimal]: + """(condition id, outcome id, price paid for the outcome) for a request.""" + condition_id, outcome_id = outcome_of(request.market_id, request.side, outcomes) + price = D(request.price) + return condition_id, outcome_id, price if request.side == Side.BUY else Decimal(1) - price def build_bet( @@ -311,6 +507,148 @@ def relayer_fee_of(info: Any) -> int: return int(D(row["relayerFeeAmount"])) +# --------------------------------------------------------------------------- +# Combos +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ComboLeg: + """One leg of a combo: a market and the side backed there, as an order + names them (`buy` its YES outcome, `sell` the other outcome of a + two-outcome market).""" + + market_id: str + side: Side = Side.BUY + + +def legs_of(legs: list[ComboLeg | str | tuple[str, Side | str]]) -> list[ComboLeg]: + """Legs given as `ComboLeg`s, market ids (backing YES) or `(market_id, side)` pairs.""" + out = [] + for leg in legs: + if isinstance(leg, ComboLeg): + out.append(leg) + elif isinstance(leg, str): + out.append(ComboLeg(leg)) + else: + market_id, side = leg + out.append(ComboLeg(market_id, Side(side))) + return out + + +@dataclass +class ComboQuote: + """What the pool would take a combo at now: its total `odds` by the + protocol's combo rule and their `price` (`1 / odds`, per contract), the + pool's limits on it in the bet token, and every reason it would be + refused (`problems`; none when `eligible`).""" + + legs: list[dict[str, Any]] + odds: Decimal | None + price: Decimal | None + currency: str + min_bet: Decimal | None = None + max_bet: Decimal | None = None + max_payout: Decimal | None = None + problems: list[str] = field(default_factory=list) + info: dict[str, Any] = field(default_factory=dict) + + @property + def eligible(self) -> bool: + return not self.problems + + +def _decimal(value: Any) -> Decimal | None: + return D(value) if value not in (None, "") else None + + +def combo_quote_of( + legs: list[ComboLeg], selections: list[tuple[str, str]], conditions: dict[str, dict[str, Any]], + limits: dict[str, Any] | None, *, currency: str, stake: Decimal | None = None, +) -> ComboQuote: + """A combo's quote from its legs' conditions (`Azuro.fetch_conditions`) + and the pool's limits on it (`/bet/calculation`), with the checks the + venue's SDK makes before it signs one: two or more legs, each condition + once and each game once, no condition closed to combos + (`isExpressForbidden`), every condition and outcome taking bets, a + maximum stake above zero, and the stake within the limits.""" + problems: list[str] = [] + out_legs: list[dict[str, Any]] = [] + if len(selections) < MIN_COMBO_LEGS: + problems.append(f"a combo needs at least {MIN_COMBO_LEGS} legs") + seen_conditions: set[str] = set() + seen_games: dict[str, str] = {} + leg_odds: list[Decimal] = [] + for leg, (condition_id, outcome_id) in zip(legs, selections): + condition = conditions.get(condition_id) + game_id = game_of_condition(condition_id) or str(((condition or {}).get("game") or {}).get("gameId") or "") or None + entry: dict[str, Any] = {"market_id": ids.qualify(VENUE, ids.native(VENUE, leg.market_id)), "side": leg.side, + "condition_id": condition_id, "outcome_id": outcome_id, "game_id": game_id, + "odds": None} + out_legs.append(entry) + if condition_id in seen_conditions: + problems.append(f"condition {condition_id} is in the combo twice") + seen_conditions.add(condition_id) + if game_id is not None: + if game_id in seen_games and seen_games[game_id] != condition_id: + problems.append(f"game {game_id} is in the combo twice; legs must be on different games") + seen_games.setdefault(game_id, condition_id) + if condition is None: + problems.append(f"no condition {condition_id}") + continue + if condition.get("isExpressForbidden"): + problems.append(f"condition {condition_id} is not offered in combos") + if str(condition.get("state") or "") != "Active": + problems.append(f"condition {condition_id} is {condition.get('state') or 'not taking bets'}") + outcome = next((o for o in condition.get("outcomes") or [] if str(o.get("outcomeId")) == outcome_id), None) + if outcome is None: + problems.append(f"condition {condition_id} has no outcome {outcome_id}") + continue + if str(outcome.get("state") or "Active") != "Active" or outcome.get("hidden") is True: + problems.append(f"outcome {outcome_id} of {condition_id} is not taking bets") + odds = odds_value(outcome_odds(outcome)) + entry["odds"] = odds + if odds is None: + problems.append(f"outcome {outcome_id} of {condition_id} has no odds") + else: + leg_odds.append(odds) + total = sig.combo_odds(leg_odds) if leg_odds and len(leg_odds) == len(selections) else None + if total is not None and total <= 1: + problems.append(f"the combo's odds {total} are too low") + raw = limits or {} + min_bet, max_bet, max_payout = _decimal(raw.get("minBet")), _decimal(raw.get("maxBet")), _decimal(raw.get("maxPayout")) + if limits is not None: + if max_bet is not None and max_bet <= 0: + problems.append("the pool takes nothing on this combo now") + elif stake is not None and max_bet is not None and stake > max_bet: + problems.append(f"a stake of {stake} is above the pool's maximum of {max_bet}") + if stake is not None and min_bet is not None and stake < min_bet: + problems.append(f"a stake of {stake} is below the pool's minimum of {min_bet}") + return ComboQuote( + legs=out_legs, odds=total, price=(Decimal(1) / total) if total else None, currency=currency, + min_bet=min_bet, max_bet=max_bet, max_payout=max_payout, problems=problems, + info={"limits": raw, "conditions": conditions}, + ) + + +def build_combo( + selections: list[tuple[str, str]], *, chain: Chain, signer: WalletSigner, quantity: Decimal, price: Decimal, + relayer_fee: int, affiliate: str | None = None, nonce: int, now: float | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """`(body, combo)` for a combo, signed, before anything is sent: `quantity` + contracts (what it pays if every leg wins) at a worst `price` per contract + for the whole combo, which is minimum total odds `1 / price`.""" + if len(selections) < MIN_COMBO_LEGS: + raise InvalidOrder(f"{VENUE}: a combo needs at least {MIN_COMBO_LEGS} legs") + price = D(price) + data = sig.client_data(chain, affiliate=affiliate or signer.address, + expires_at=int(time.time() if now is None else now) + DEADLINE_S, relayer_fee=relayer_fee) + combo = sig.combo_bet(legs=selections, min_odds=sig.min_odds(price), + amount=sig.stake(D(quantity), price, chain.decimals), nonce=nonce) + body = sig.wire_combo(chain, bettor=signer.address, data=data, combo=combo, + signature=sig.sign_combo(signer, data, combo)) + return body, combo + + # --------------------------------------------------------------------------- # Adapter # --------------------------------------------------------------------------- @@ -417,7 +755,8 @@ async def outcomes_of(self, condition_ids: list[str]) -> dict[str, list[str]]: return {c: self._outcomes.get(c, []) for c in condition_ids} async def _orders_from(self, rows: list[dict[str, Any]]) -> dict[str, list[str]]: - return await self.outcomes_of([str(_leg(r).get("conditionId") or "") for r in rows]) + """The outcomes of every leg's condition in these rows.""" + return await self.outcomes_of([str(leg.get("conditionId") or "") for r in rows for leg in _legs(r)]) def _next_nonce(self) -> int: """Milliseconds, as the venue's SDK uses, never repeated in this process.""" @@ -496,7 +835,8 @@ async def fetch_order(self, order_id: str) -> Order: if not isinstance(row, dict) or not row.get("id"): raise OrderNotFound(f"{VENUE}: no bet order {order_id}") outcomes = await self._orders_from([row]) - return order_of_row(row, outcomes=outcomes.get(str(_leg(row).get("conditionId") or "")), account=self.account) + return order_of_row(row, outcomes=outcomes.get(str(_leg(row).get("conditionId") or "")), account=self.account, + legs_outcomes=outcomes) async def _bettor_rows(self, *, states: list[str] | None = None, result: list[str] | None = None, offset: int = 0, limit: int | None = None) -> list[dict[str, Any]]: @@ -534,8 +874,8 @@ async def fetch_orders( offset, size = int(cursor or 0), min(limit or 50, ORDERS_PAGE) rows = await self._bettor_rows(states=states, offset=offset, limit=size) outcomes = await self._orders_from(rows) - orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account) - for r in rows] + orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account, + legs_outcomes=outcomes) for r in rows] if status == "closed": orders = [o for o in orders if o.is_terminal] if market_id: @@ -549,8 +889,8 @@ async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order """Bets signed and not yet on chain: nothing rests in a pool.""" rows = await self._bettor_rows(states=list(PENDING_STATES)) outcomes = await self._orders_from(rows) - orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account) - for r in rows] + orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account, + legs_outcomes=outcomes) for r in rows] if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) orders = [o for o in orders if o.market_id == wanted] @@ -581,14 +921,17 @@ async def fetch_settlements( self, *, market_id: str | None = None, since: int | None = None, limit: int | None = None, cursor: str | None = None, ) -> Page[Settlement]: - """This wallet's settled single bets: won, lost or cancelled.""" + """This wallet's settled bets, singles and combos: won, lost or + cancelled, each with whether its payout is claimed (`info["claimed"]`, + `info["claimable"]`).""" if cursor is not None and not cursor.isdigit(): raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") offset, size = int(cursor or 0), min(limit or 50, ORDERS_PAGE) rows = await self._bettor_rows(result=["Won", "Lost", "Canceled"], offset=offset, limit=size) outcomes = await self._orders_from(rows) out = [s for s in (settlement_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), - account=self.account) for r in rows) if s is not None] + account=self.account, legs_outcomes=outcomes) for r in rows) + if s is not None] if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) out = [s for s in out if s.market_id == wanted] @@ -621,37 +964,70 @@ async def approve(self, amount: Decimal | None = None, *, wait: bool = True, tim default, as the venue's SDK does, or `amount` tokens. An on-chain transaction from the wallet, which pays its gas in the chain's coin.""" value = MAX_UINT256 if amount is None else int(D(amount) * (Decimal(10) ** self.chain.decimals)) - data = sig.approve_calldata(self.chain.relayer, value) + call = {"to": self.chain.token, "data": sig.approve_calldata(self.chain.relayer, value)} + estimate = await self._estimate(call, what="approval") + [sent] = await self._send_calls([(call, estimate)]) + if not wait: + return {"transaction_hash": sent} + return {"transaction_hash": sent, "receipt": await self._receipt(sent, timeout_s=timeout_s, what="approval")} + + # -- wallet transactions -------------------------------------------------- + + async def _estimate(self, call: dict[str, Any], *, what: str) -> int: + """The gas a call from the wallet takes; a call that would revert is + refused here, named by the contract's error where it is one of the + pool's, before anything is sent.""" + try: + return int(await self._rpc("eth_estimateGas", [{"from": self.address, "to": call["to"], + "data": call["data"]}]), 16) + except ExchangeError as exc: + error = (exc.body or {}).get("error") if isinstance(exc.body, dict) else None + data = str((error or {}).get("data") or "") if isinstance(error, dict) else "" + name = sig.PAYOUT_ERRORS.get(data[:10].lower()) + reason = name or (str(error.get("message")) if isinstance(error, dict) else "reverted") + raise OrderRejected(f"{VENUE}: {what} would revert: {reason}", reason=(name or "reverted").lower(), + info={"call": call, "error": error}) from None + + async def _send_calls(self, calls: list[tuple[dict[str, Any], int]]) -> list[str]: + """Sign and send calls from the wallet, one transaction each with + consecutive nonces; their hashes. The wallet pays the gas.""" nonce = int(await self._rpc("eth_getTransactionCount", [self.address, "pending"]), 16) gas_price = int(await self._rpc("eth_gasPrice", []), 16) priority_fee = int(await self._rpc("eth_maxPriorityFeePerGas", []), 16) - estimate = int(await self._rpc("eth_estimateGas", [{"from": self.address, "to": self.chain.token, "data": data}]), 16) - tx = { - "chainId": self.chain.chain_id, "nonce": nonce, "to": self.chain.token, "value": 0, "data": data, - "gas": int(estimate * 1.2), "maxFeePerGas": gas_price * 2 + priority_fee, - "maxPriorityFeePerGas": priority_fee, "type": 2, - } - tx_hash = await self._rpc("eth_sendRawTransaction", [self.signer.sign_transaction(tx)]) - if not wait: - return {"transaction_hash": tx_hash} + hashes = [] + for offset, (call, estimate) in enumerate(calls): + tx = { + "chainId": self.chain.chain_id, "nonce": nonce + offset, "to": call["to"], "value": 0, + "data": call["data"], "gas": int(estimate * 1.2), "maxFeePerGas": gas_price * 2 + priority_fee, + "maxPriorityFeePerGas": priority_fee, "type": 2, + } + hashes.append(await self._rpc("eth_sendRawTransaction", [self.signer.sign_transaction(tx)])) + return hashes + + async def _receipt(self, tx_hash: str, *, timeout_s: float, what: str, poll_s: float = 2.0) -> dict[str, Any]: loop = asyncio.get_running_loop() stop = loop.time() + timeout_s while True: receipt = await self._rpc("eth_getTransactionReceipt", [tx_hash]) if receipt: if int(receipt.get("status", "0x0"), 16) != 1: - raise OrderRejected(f"{VENUE}: approval {tx_hash} reverted", reason="reverted", info=receipt) - return {"transaction_hash": tx_hash, "receipt": receipt} + raise OrderRejected(f"{VENUE}: {what} {tx_hash} reverted", reason="reverted", info=receipt) + return receipt if loop.time() >= stop: - raise ExchangeError(f"{VENUE}: approval {tx_hash} unmined after {timeout_s}s; it may yet confirm") - await asyncio.sleep(2.0) + raise ExchangeError(f"{VENUE}: {what} {tx_hash} unmined after {timeout_s}s; it may yet confirm") + await asyncio.sleep(poll_s) async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: - """Accepted, unsettled single bets netted per market on the YES leg.""" + """Accepted, unsettled single bets netted per market on the YES leg, + and settled single bets whose payout waits to be claimed (`resolved`, + the amount in `redeemable`).""" rows = await self._bettor_rows(states=["Accepted", "CancelFailed"]) rows = [r for r in rows if r.get("result") in (None, "")] - outcomes = await self._orders_from(rows) + claims = await self.fetch_redeemable_bets() + outcomes = await self.outcomes_of([str(leg.get("conditionId") or "") for r in rows for leg in _legs(r)] + + [b["legs"][0][0] for b in claims if not b["combo"] and b["legs"]]) positions = positions_of(rows, outcomes=outcomes, account=self.account) + positions += claimable_positions_of(claims, outcomes=outcomes, decimals=self.chain.decimals, account=self.account) if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) positions = [p for p in positions if p.market_id == wanted] @@ -671,6 +1047,166 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency=self.chain.symbol, info={"charged": "per bet, to the relayer, for the transaction"}, ) + # -- combos --------------------------------------------------------------- + + async def _combo_selections(self, legs: list[ComboLeg]) -> list[tuple[str, str]]: + conditions = [parse_market_id(ids.native(VENUE, leg.market_id))[0] for leg in legs] + outcomes = await self.outcomes_of(conditions) + for condition_id in conditions: + if not outcomes[condition_id]: + raise MarketNotFound(f"{VENUE}: no condition {condition_id}") + return [outcome_of(leg.market_id, leg.side, outcomes[c]) for leg, c in zip(legs, conditions)] + + async def fetch_combo_limits(self, selections: list[tuple[str, str]]) -> dict[str, Any]: + """The pool's limits on these legs together (`/bet/calculation`), for + this wallet: `minBet`, `maxBet` and `maxPayout` in the bet token.""" + raw = await self._call("POST", "/bet/calculation", body={ + "bets": [{"conditionId": c, "outcomeId": int(o)} for c, o in selections], + "environment": self.chain.environment, "wallet": self.address, + }) + raw = (raw or {}).get("response", raw) if isinstance(raw, dict) else {} + return raw if isinstance(raw, dict) else {} + + async def fetch_combo_quote( + self, legs: list[ComboLeg | str | tuple[str, Side | str]], *, stake: Decimal | None = None, + ) -> ComboQuote: + """What the pool would take a combo of these legs at now: the total + odds by the protocol's combo rule from the legs' current odds, their + price, the pool's limits on the combo, and why it would be refused + (`problems`). `stake` (in the bet token) is checked against the + limits. Nothing is signed or sent.""" + legs = legs_of(legs) + selections = await self._combo_selections(legs) + conditions = await asyncio.to_thread(self.catalog.fetch_conditions, [c for c, _ in selections]) + limits: dict[str, Any] | None = None + refused = None + try: + limits = await self.fetch_combo_limits(selections) + except (BadRequest, MarketNotFound) as exc: + refused = str(exc) + quote = combo_quote_of(legs, selections, conditions, limits, currency=self.chain.symbol, + stake=D(stake) if stake is not None else None) + if refused: + quote.problems.append(f"the pool's limits were refused: {refused}") + return quote + + async def create_combo_order( + self, legs: list[ComboLeg | str | tuple[str, Side | str]], *, quantity: Decimal, price: Decimal, + client_order_id: str | None = None, check: bool = True, + ) -> Order: + """Sign one combo and hand it to the relayer (`/bet/orders/combo`): + `quantity` contracts, what it pays if every leg wins, at a worst + `price` per contract for the whole combo, so its minimum total odds + are `1 / price` and its stake `quantity * price`. A market order with + a worst price, like a single bet: the pool takes it whole at its odds + when it reaches the chain, or refuses it. With `check` (the default) + the quote's problems are raised before anything is signed. Returns the + order pending, on its first leg's market, marked `info["combo"]`; + follow it with `fetch_order` or `wait_for_order`.""" + legs = legs_of(legs) + price, quantity = D(price), D(quantity) + selections = await self._combo_selections(legs) + if check: + quote = await self.fetch_combo_quote(legs, stake=quantity * price) + if quote.problems: + raise InvalidOrder(f"{VENUE}: combo refused before signing: {'; '.join(quote.problems)}") + body, combo = build_combo(selections, chain=self.chain, signer=self.signer, quantity=quantity, price=price, + relayer_fee=await self.relayer_fee(), affiliate=self.credentials.affiliate, + nonce=self._next_nonce()) + try: + answer = await self._call("POST", "/bet/orders/combo", body=body, kind="write") or {} + except (BadRequest, MarketNotFound) as exc: + raise error_of(exc) from None + state = str(answer.get("state") or "") + if state != "Created" or not answer.get("id"): + raise OrderRejected(f"{VENUE}: combo not created: {answer.get('errorMessage') or state or answer}", + reason=str(answer.get("error") or state or "rejected").lower(), info=answer) + outcomes = await self.outcomes_of([c for c, _ in selections]) + named = [dict(zip(("market_id", "side"), market_of(c, o, outcomes[c])), condition_id=c, outcome_id=o) + for c, o in selections] + paid = Decimal(sig.ODDS_SCALE) / Decimal(int(combo["minOdds"])) + cost = sig.token_amount(combo["amount"], self.chain.decimals) + return Order( + id=str(answer["id"]), client_order_id=client_order_id, venue=VENUE, account=self.account, + market_id=named[0]["market_id"], side=Side.BUY, type=OrderType.MARKET, time_in_force=TimeInForce.FOK, + status=OrderStatus.PENDING, price=paid, quantity=(cost / paid).quantize(Decimal("0.000001")), + filled=Decimal("0"), cost=cost, created_at=int(time.time() * 1000), + info={"answer": answer, "combo": True, "bet": combo, "legs": named}, + ) + + # -- claims --------------------------------------------------------------- + + async def _redeemable_page(self, skip: int) -> list[dict[str, Any]]: + await self.limiter.acquire(cost=1, kind="read", priority=Priority.NORMAL) # type: ignore[arg-type] + raw = await self.http.request("POST", self.chain.subgraph_url, json={ + "query": REDEEMABLE_QUERY, + "variables": {"first": SUBGRAPH_PAGE, "skip": skip, + "where": {"actor": self.address.lower(), "isRedeemable": True, "isCashedOut": False}}, + }) + if not isinstance(raw, dict) or raw.get("errors") or not isinstance(raw.get("data"), dict): + raise ExchangeError(f"{VENUE}: bets subgraph: {raw}", body=raw) + return [b for b in raw["data"].get("v3Bets") or [] if isinstance(b, dict)] + + async def fetch_redeemable_bets(self) -> list[dict[str, Any]]: + """This wallet's bets whose payout is still in the pool, from the bets + subgraph: won, or cancelled (the stake returned), not cashed out, not + yet claimed. Each is `{"bet_id", "combo", "result" ("won" or + "canceled"), "amount" (what claiming pays, in the bet token), "stake", + "core", "lp", "freebet_id", "paymaster", "legs", "info"}`.""" + bets: list[dict[str, Any]] = [] + for page in range(MAX_SUBGRAPH_PAGES): + rows = await self._redeemable_page(page * SUBGRAPH_PAGE) + bets += [b for b in (redeemable_of_bet(r) for r in rows) if b is not None] + if len(rows) < SUBGRAPH_PAGE: + break + return bets + + async def fetch_claimable(self, bet_id: str | int, *, core: str | None = None) -> Decimal: + """What claiming one bet would pay now, read on chain from the pool + (`viewPayout`): zero once claimed, for a lost bet, or before it settles.""" + raw = await self._rpc("eth_call", [{"to": self.chain.lp, "data": sig.view_payout_calldata( + core or self.chain.core, bet_id)}, "latest"]) + return sig.token_amount(int(raw, 16), self.chain.decimals) + + async def redeem( + self, bet_ids: list[str | int] | None = None, *, wait: bool = True, timeout_s: float = 120.0, + batch: int = CLAIM_BATCH, + ) -> dict[str, Any]: + """Claim won and cancelled bets' payouts into the wallet: every bet + `fetch_redeemable_bets` lists, or those of `bet_ids`. One + `withdrawPayouts` transaction to the pool per `batch` bets, one per + free bet (the paymaster's), sent from the wallet, which pays the gas in + the chain's coin. Each call is first estimated, so one that would + revert (already claimed: `alreadypaid`) is refused before anything is + sent. Returns `{"transactions": [{"transaction_hash", "to", + "bet_ids", "amount", "receipt"}], "bet_ids", "amount", "currency"}`; + nothing to claim sends nothing.""" + bets = await self.fetch_redeemable_bets() + if bet_ids is not None: + wanted = [str(b) for b in bet_ids] + known = {b["bet_id"] for b in bets} + missing = [b for b in wanted if b not in known] + if missing: + raise InvalidOrder(f"{VENUE}: nothing to claim on bet {', '.join(missing)}: not won or cancelled, " + "cashed out, already claimed, or not this wallet's") + bets = [b for b in bets if b["bet_id"] in wanted] + calls = claim_calls(bets, chain=self.chain, batch=batch) + if not calls: + return {"transactions": [], "bet_ids": [], "amount": Decimal("0"), "currency": self.chain.symbol} + estimates = [await self._estimate(call, what=f"claim of bet {', '.join(call['bet_ids'])}") for call in calls] + hashes = await self._send_calls(list(zip(calls, estimates))) + out = [] + for call, tx_hash in zip(calls, hashes): + entry: dict[str, Any] = {"transaction_hash": tx_hash, "to": call["to"], "bet_ids": call["bet_ids"], + "amount": sig.token_amount(call["raw_amount"], self.chain.decimals)} + if wait: + entry["receipt"] = await self._receipt(tx_hash, timeout_s=timeout_s, what="claim") + out.append(entry) + return {"transactions": out, "bet_ids": [b for call in calls for b in call["bet_ids"]], + "amount": sum((e["amount"] for e in out), Decimal("0")), "currency": self.chain.symbol} + + claim_winnings = redeem + # -- cash-out ------------------------------------------------------------- async def fetch_cashout_quote(self, bet_id: str | int) -> dict[str, Any] | None: @@ -719,4 +1255,7 @@ async def close(self) -> None: self.catalog.close() -__all__ = ["AzuroTrading", "order_of_row", "fill_of_row", "settlement_of_row", "positions_of", "build_bet"] +__all__ = [ + "AzuroTrading", "ComboLeg", "ComboQuote", "order_of_row", "fill_of_row", "settlement_of_row", "positions_of", + "claimable_positions_of", "build_bet", "build_combo", "combo_quote_of", "redeemable_of_bet", "claim_calls", +] diff --git a/synpath/trading/azuro_signing.py b/synpath/trading/azuro_signing.py index 0ccd8b5..ff207fe 100644 --- a/synpath/trading/azuro_signing.py +++ b/synpath/trading/azuro_signing.py @@ -1,8 +1,11 @@ -"""Azuro bet and cash-out signing, and the amounts in them, with no network in sight. +"""Azuro bet, combo and cash-out signing, the amounts in them, and the pool's +payout calls, with no network in sight. Built to `@azuro-org/toolkit` 7.2.0 (`getBetTypedData`, `createBet`, -`getCashoutTypedData`, `createCashout`), and checked against digests and -signatures that toolkit and viem produce (`tests/samples/azuro_signing_vectors.json`). +`getComboBetTypedData`, `createComboBet`, `calcMinOdds`, `getCashoutTypedData`, +`createCashout`, the LP and PayMaster ABIs) and `@azuro-org/sdk` 8.2.0 +(`useRedeemBet`), and checked against digests, signatures and calldata that +toolkit and viem produce (`tests/samples/azuro_signing_vectors.json`). **A bet** is the EIP-712 message `ClientBetData`: the `ClientData` (a consent text, the affiliate, the pool's core contract, an expiry, the chain id, the @@ -17,6 +20,17 @@ names its worst acceptable odds; the YES price given is turned into them as `1 / price`, rounded up, so a bet never pays more per contract than asked. +**A combo** is the EIP-712 message `ClientComboBetData` under the same +domain: the same `ClientData`, one minimum for the total odds, one stake, one +nonce, and two or more `ComboPart`s (a condition and an outcome each). Its +total odds are the protocol's combo rule: each leg's odds with the feed's +fee taken off (divided by 0.99) and rounded up to two decimals, multiplied +together, and the fee taken once from the product (times 0.99). + +**A payout** is claimed on chain from the pool's liquidity contract: +`withdrawPayouts(core, betIds)` for any number of one core's bets, paid to +each bet's owner; a free bet's from the paymaster, `withdrawPayouts(freeBetIds)`. + **A cash-out** is `CashOutOrder` (a consent text, the chain id, one `CashOutItem` -- the bet's id, the core contract, the minimum odds -- and an expiry) under the domain `Cash Out` version `1.0.0`, the chain's id and the @@ -69,6 +83,20 @@ {"name": "nonce", "type": "uint256"}, ], } +COMBO_BET_TYPES: dict[str, list[dict[str, str]]] = { + "ClientComboBetData": [ + {"name": "clientData", "type": "ClientData"}, + {"name": "minOdds", "type": "uint64"}, + {"name": "amount", "type": "uint128"}, + {"name": "nonce", "type": "uint256"}, + {"name": "bets", "type": "ComboPart[]"}, + ], + "ClientData": CLIENT_DATA_FIELDS, + "ComboPart": [ + {"name": "conditionId", "type": "uint256"}, + {"name": "outcomeId", "type": "uint128"}, + ], +} CASHOUT_TYPES: dict[str, list[dict[str, str]]] = { "CashOutItem": [ {"name": "betId", "type": "uint256"}, @@ -121,6 +149,29 @@ def token_amount(raw: int | str, decimals: int) -> Decimal: return Decimal(int(raw)) / (Decimal(10) ** decimals) +COMBO_FEE_MODIFIER = Decimal("0.99") +"""The toolkit's `ODDS_COMBO_FEE_MODIFIER`: the feed's fee on every outcome's odds.""" + +CENT = Decimal("0.01") + + +def combo_odds(leg_odds: list[Decimal | str | float]) -> Decimal: + """A combo's total odds by the protocol's rule: each leg's odds over + 0.99, rounded up to two decimals, multiplied, times 0.99, exactly. The + bets subgraph records accepted combos at these odds (1.58 and 1.58 make + 2.5344), cut to the protocol's twelve decimals. The toolkit's + `calcMinOdds` cuts the same figure to two.""" + if not leg_odds: + raise InvalidOrder("azuro: a combo needs legs") + total = Decimal(1) + for odds in leg_odds: + value = Decimal(str(odds)) + if value <= 1: + raise InvalidOrder(f"azuro: odds {value} take no bets") + total *= (value / COMBO_FEE_MODIFIER).quantize(CENT, rounding=ROUND_CEILING) + return (total * COMBO_FEE_MODIFIER).quantize(Decimal(1) / ODDS_SCALE, rounding=ROUND_DOWN) + + # --------------------------------------------------------------------------- # EIP-712 # --------------------------------------------------------------------------- @@ -209,14 +260,19 @@ def bet_domain(data: dict[str, Any]) -> dict[str, Any]: "verifyingContract": data["core"]} +def client_message(data: dict[str, Any]) -> dict[str, Any]: + """`ClientData` as it is signed: numbers as integers.""" + return { + **{k: data[k] for k in ("attention", "affiliate", "core", "isFeeSponsored", "isBetSponsored", + "isSponsoredBetReturnable")}, + "expiresAt": int(data["expiresAt"]), "chainId": int(data["chainId"]), + "relayerFeeAmount": int(data["relayerFeeAmount"]), + } + + def bet_message(data: dict[str, Any], bet: dict[str, Any]) -> dict[str, Any]: return { - "clientData": { - **{k: data[k] for k in ("attention", "affiliate", "core", "isFeeSponsored", "isBetSponsored", - "isSponsoredBetReturnable")}, - "expiresAt": int(data["expiresAt"]), "chainId": int(data["chainId"]), - "relayerFeeAmount": int(data["relayerFeeAmount"]), - }, + "clientData": client_message(data), "bets": [{k: int(bet[k]) for k in ("conditionId", "outcomeId", "minOdds", "amount", "nonce")}], } @@ -254,6 +310,61 @@ def wire_bet(chain: Chain, *, bettor: str, data: dict[str, Any], bet: dict[str, } +# --------------------------------------------------------------------------- +# Combos +# --------------------------------------------------------------------------- + +def combo_bet(*, legs: list[tuple[str, str | int]], min_odds: int, amount: int, nonce: int) -> dict[str, Any]: + """The combo's own fields, as decimal strings: the minimum total odds, + the stake, the nonce, and its `(condition, outcome)` legs in order.""" + return {"minOdds": str(int(min_odds)), "amount": str(int(amount)), "nonce": str(int(nonce)), + "bets": [{"conditionId": str(c), "outcomeId": str(o)} for c, o in legs]} + + +def combo_message(data: dict[str, Any], combo: dict[str, Any]) -> dict[str, Any]: + return { + "clientData": client_message(data), + "minOdds": int(combo["minOdds"]), "amount": int(combo["amount"]), "nonce": int(combo["nonce"]), + "bets": [{"conditionId": int(b["conditionId"]), "outcomeId": int(b["outcomeId"])} for b in combo["bets"]], + } + + +def combo_typed_data(data: dict[str, Any], combo: dict[str, Any]) -> dict[str, Any]: + """The combo as EIP-712 typed data, for wallets that sign that.""" + return { + "primaryType": "ClientComboBetData", + "types": {"EIP712Domain": DOMAIN_FIELDS, **COMBO_BET_TYPES}, + "domain": bet_domain(data), + "message": combo_message(data, combo), + } + + +def combo_digest(data: dict[str, Any], combo: dict[str, Any]) -> bytes: + return digest(bet_domain(data), "ClientComboBetData", COMBO_BET_TYPES, combo_message(data, combo)) + + +def sign_combo(signer: WalletSigner, data: dict[str, Any], combo: dict[str, Any]) -> str: + return "0x" + bytes(signer.sign_digest(combo_digest(data, combo))).hex() + + +def wire_combo(chain: Chain, *, bettor: str, data: dict[str, Any], combo: dict[str, Any], + signature: str) -> dict[str, Any]: + """The signed combo as `POST /bet/orders/combo` takes it.""" + return { + "environment": chain.environment, + "bettor": bettor.lower(), + "betOwner": bettor.lower(), + "clientBetData": { + "clientData": data, + "amount": combo["amount"], + "minOdds": combo["minOdds"], + "nonce": combo["nonce"], + "bets": [{"conditionId": b["conditionId"], "outcomeId": int(b["outcomeId"])} for b in combo["bets"]], + }, + "bettorSignature": signature, + } + + # --------------------------------------------------------------------------- # Cash-out # --------------------------------------------------------------------------- @@ -314,3 +425,40 @@ def allowance_calldata(owner: str, spender: str) -> str: def balance_calldata(owner: str) -> str: abi_encode, _, _, keccak, _ = _eth() return "0x" + (keccak(text="balanceOf(address)")[:4] + abi_encode(["address"], [owner])).hex() + + +def _call(signature: str, types: list[str], args: list[Any]) -> str: + abi_encode, _, _, keccak, _ = _eth() + return "0x" + (keccak(text=signature)[:4] + abi_encode(types, args)).hex() + + +def withdraw_payouts_calldata(core: str, bet_ids: list[int | str]) -> str: + """`LP.withdrawPayouts(core, betIds)`: every one of one core's bets in one + transaction, each paid to its owner; a lost bet in the batch pays 0.""" + _, _, _, _, to_checksum_address = _eth() + return _call("withdrawPayouts(address,uint256[])", ["address", "uint256[]"], + [to_checksum_address(core), [int(b) for b in bet_ids]]) + + +def withdraw_payout_calldata(core: str, bet_id: int | str) -> str: + """`LP.withdrawPayout(core, betId)`: one bet, as a legacy pool takes it.""" + _, _, _, _, to_checksum_address = _eth() + return _call("withdrawPayout(address,uint256)", ["address", "uint256"], [to_checksum_address(core), int(bet_id)]) + + +def view_payout_calldata(core: str, bet_id: int | str) -> str: + """`LP.viewPayout(core, betId)`: what claiming the bet would pay now.""" + _, _, _, _, to_checksum_address = _eth() + return _call("viewPayout(address,uint256)", ["address", "uint256"], [to_checksum_address(core), int(bet_id)]) + + +def paymaster_withdraw_payouts_calldata(freebet_ids: list[int | str]) -> str: + """`PayMaster.withdrawPayouts(freeBetIds)`: a free bet's payout.""" + return _call("withdrawPayouts(uint256[])", ["uint256[]"], [[int(f) for f in freebet_ids]]) + + +PAYOUT_ERRORS = { + "0xd70a0e30": "AlreadyPaid", "0xf67321e5": "BetNotExists", "0xeafd377b": "CobmoBetNotResolved", + "0x3ef97a7b": "ComboBetResolvedPartially", "0xacb88053": "ConditionNotFinished", +} +"""The core's revert selectors a claim can meet, by name (the ABI's spelling).""" diff --git a/tests/samples/azuro_redeemable_bets.json b/tests/samples/azuro_redeemable_bets.json new file mode 100644 index 0000000..8d06776 --- /dev/null +++ b/tests/samples/azuro_redeemable_bets.json @@ -0,0 +1,923 @@ +{ + "data": { + "v3Bets": [ + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_467993", + "nonce": "1792453197498648", + "tokenId": "467993", + "actor": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "amount": "2", + "rawAmount": "2000000", + "status": "Resolved", + "potentialPayout": "5.84", + "rawPotentialPayout": "5840000", + "payout": "5.84", + "rawPayout": "5840000", + "result": "Won", + "odds": "2.92", + "settledOdds": "2.92", + "createdAt": "1792453202", + "resolvedAt": "1792887459", + "txHash": "0x5eebe42c514d4446b56c946d4145bcb326c073b4bac695d6de5f40f9b05fa028", + "redeemedTxHash": null, + "redeemedAt": null, + "affiliate": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "isRedeemed": false, + "isRedeemable": true, + "isCashedOut": false, + "type": "Ordinar", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "2.92", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "Draw", + "result": "Won", + "condition": { + "conditionId": "600610060000000000901243060000000000000131378013", + "title": "Match Result", + "status": "Resolved", + "gameId": "1006000000000090124306", + "wonOutcomeIds": [ + "3" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_467650", + "nonce": "1792436638282604", + "tokenId": "467650", + "actor": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "amount": "1", + "rawAmount": "1000000", + "status": "Resolved", + "potentialPayout": "1.53", + "rawPotentialPayout": "1530000", + "payout": "1.53", + "rawPayout": "1530000", + "result": "Won", + "odds": "1.53", + "settledOdds": "1.53", + "createdAt": "1792436642", + "resolvedAt": "1792886403", + "txHash": "0x9f8d41cd2c4828a2c7cbd97435dd19a2c36479d7d66cabf5d5f270a988f4f75b", + "redeemedTxHash": null, + "redeemedAt": null, + "affiliate": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "isRedeemed": false, + "isRedeemable": true, + "isCashedOut": false, + "type": "Ordinar", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.53", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "CSYD Madryn", + "result": "Won", + "condition": { + "conditionId": "600610060000000000942518730000000000001216194534", + "title": "Match Result", + "status": "Resolved", + "gameId": "1006000000000094251873", + "wonOutcomeIds": [ + "3" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472005", + "nonce": "1792686315014", + "tokenId": "472005", + "actor": "0xed425fd3187907d9a2b73e8bc2b8eecf3b2b5ac2", + "amount": "6.9", + "rawAmount": "6900000", + "status": "Resolved", + "potentialPayout": "17.48736", + "rawPotentialPayout": "17487360", + "payout": "17.48736", + "rawPayout": "17487360", + "result": "Won", + "odds": "2.5344", + "settledOdds": "2.5344", + "createdAt": "1792686321", + "resolvedAt": "1792693640", + "txHash": "0xa530adc77f42c72256cfd7ed01cf9b14e83428d9f9be5c622e39015c21ec4d0a", + "redeemedTxHash": "0x95a16fc4d29589b13e82e88f81104a320192a5caf37eeca7cd278fd65250b43b", + "redeemedAt": "1792745766", + "affiliate": "0x82875517c2b4bd534de4b2c0d21bff5f40b25da6", + "isRedeemed": true, + "isRedeemable": false, + "isCashedOut": false, + "type": "Express", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.58", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": null, + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514730000000000000737294024", + "title": "Total Goals Over/Under", + "status": "Resolved", + "gameId": "1006000000000091651473", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.58", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Over 2.5", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515400000000000000736020810", + "title": "Total Goals Over/Under", + "status": "Resolved", + "gameId": "1006000000000091651540", + "wonOutcomeIds": [ + "2" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472056", + "nonce": "1792688164836", + "tokenId": "472056", + "actor": "0x8d693762eab04504e77f4ed8ec5251e48fc360d4", + "amount": "10", + "rawAmount": "10000000", + "status": "Canceled", + "potentialPayout": "19.3", + "rawPotentialPayout": "19300000", + "payout": "10", + "rawPayout": "10000000", + "result": null, + "odds": "1.93", + "settledOdds": "1", + "createdAt": "1792688184", + "resolvedAt": "1792688325", + "txHash": "0x6a577d19219ff7ecae53a1ca2dcfdb21134a1268ce9856fad4b32c6f768f0164", + "redeemedTxHash": "0x034542780bd4527a8e118e6d469498201efb55f930c496a326540de0585fcd28", + "redeemedAt": "1792688513", + "affiliate": "0x82875517c2b4bd534de4b2c0d21bff5f40b25da6", + "isRedeemed": true, + "isRedeemable": false, + "isCashedOut": false, + "type": "Ordinar", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.93", + "result": "Canceled", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Over 5.5", + "result": "Won", + "condition": { + "conditionId": "600610060000000000312449060000000000001815430603", + "title": "1st Period - Total Goals Over/Under", + "status": "Resolved", + "gameId": "1006000000000031244906", + "wonOutcomeIds": [ + "1" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472057", + "nonce": "1792688227188", + "tokenId": "472057", + "actor": "0x066210a533c93eae126c82d4a7b14309dd2eed0f", + "amount": "4.03", + "rawAmount": "4030000", + "status": "Resolved", + "potentialPayout": "84.698904", + "rawPotentialPayout": "84698904", + "payout": "0", + "rawPayout": "0", + "result": "Lost", + "odds": "21.017097888654", + "settledOdds": "21.017097888654", + "createdAt": "1792688246", + "resolvedAt": "1792694669", + "txHash": "0xc5e0611b789d2b0a75f39fa580b86698a6953d2735c37166a9eea08b64e4be5e", + "redeemedTxHash": "0x47deafd7db37897a74332d3de0715eee38cb3fe00182f94bc486a6d965aee5a8", + "redeemedAt": "1792699209", + "affiliate": "0x82875517c2b4bd534de4b2c0d21bff5f40b25da6", + "isRedeemed": true, + "isRedeemable": false, + "isCashedOut": false, + "type": "Express", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.01", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Mjallby AIF (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483460000000000001815498996", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648346", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "2", + "title": "PFC CSKA-Sofia (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483670000000000000737295297", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648367", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.04", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "2", + "title": "FC Copenhagen (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483680000000000000737293675", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648368", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.1", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "2", + "title": "FK Jablonec (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483780000000000001653198308", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648378", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.06", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "FC Lugano (+2)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483920000000000001158202482", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648392", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.14", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "Inter Club de Escaldes (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483930000000000001815241020", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648393", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.08", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Heart of Midlothian FC (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916484160000000000000985481078", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648416", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.06", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "KF Egnatia (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916484170000000000000947866001", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648417", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.1", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "Atalanta (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916485180000000000000764314586", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648518", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.1", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "AS Monaco (+2)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916485300000000000000738450974", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648530", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.15", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "Sint-Truidense VV (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916485350000000000000738453218", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648535", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.05", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "FC Thun (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916487820000000000001158201838", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648782", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.11", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "Kuopio PS (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916488040000000000001158200802", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648804", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.09", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "Lincoln Red Imps FC (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916488150000000000001166475176", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648815", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "KAA Gent (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916488590000000000000738452342", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648859", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.04", + "result": "Lost", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "FC Ararat-Armenia (+4)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916514730000000000000737294086", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651473", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.12", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": null, + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514740000000000000736019218", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651474", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.11", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "AC Milan (+3)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916514750000000000000733827079", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651475", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.13", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Celtic FC (+2)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514770000000000000734889585", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651477", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Crystal Palace FC (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514780000000000000793589865", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651478", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.12", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "PFC Levski Sofia (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514810000000000000734890387", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651481", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.05", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Jagiellonia Bialystok (+3)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515100000000000000736022324", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651510", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.03", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "NEC Nijmegen (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515360000000000001279442782", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651536", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.03", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "Stade Rennais FC (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515400000000000001815328026", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651540", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.05", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "OFI Crete (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515430000000000000796841115", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651543", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.05", + "result": "Lost", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Hapoel Beer Sheva (+4)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515540000000000000737295934", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651554", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Lech Poznan (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515640000000000000737675871", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651564", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.03", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "SC Uniao Torreense (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515660000000000000737294563", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651566", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "NK Celje (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515680000000000001302681191", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651568", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.09", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Lillestrom SK (+4)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515700000000000000733827215", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651570", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.09", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Olympique Lyonnais (+3)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515720000000000000733828856", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651572", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.13", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "AC Omonia Nicosia (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515730000000000000734226777", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651573", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.05", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "AC Sparta Praha (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515740000000000000793571877", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651574", + "wonOutcomeIds": [ + "2" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + } + ] + } +} \ No newline at end of file diff --git a/tests/samples/azuro_signing_vectors.json b/tests/samples/azuro_signing_vectors.json index 6b7b8c6..2b3eb68 100644 --- a/tests/samples/azuro_signing_vectors.json +++ b/tests/samples/azuro_signing_vectors.json @@ -249,5 +249,235 @@ "socket": "wss://streams.onchainfeed.org/v1/streams", "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-chiliz-v3" } - } + }, + "combo_source": "@azuro-org/toolkit 7.2.0 getComboBetTypedData, calcMinOdds, lpAbi/paymasterAbi; viem hashTypedData, signTypedData, encodeFunctionData", + "combos": [ + { + "name": "polygon combo of two", + "chainId": 137, + "clientData": { + "attention": "By signing this transaction, I agree to place a bet for an event on Azuro Protocol", + "affiliate": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "core": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", + "expiresAt": 1792780300, + "chainId": 137, + "relayerFeeAmount": "12345", + "isBetSponsored": false, + "isFeeSponsored": false, + "isSponsoredBetReturnable": false + }, + "amount": "5000000", + "minOdds": "3270000000000", + "nonce": "1792780000001", + "bets": [ + { + "conditionId": "600610060000000000943019940000000000001802657415", + "outcomeId": "1" + }, + { + "conditionId": "600610060000000000028666020000000000001624778125", + "outcomeId": "2" + } + ], + "domain": { + "name": "Live Betting", + "version": "1.0.0", + "chainId": 137, + "verifyingContract": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7" + }, + "digest": "0x81c1b2e5926ac33dfbb99b7eff5888800a492e0883e1bd6e0f0da4b075a2c76c", + "signature": "0x72efde622b42e27aaec0502ca40f483ac97e2b0c1925a9e44ace33f6ab4739765a13f876a60a1b366c6ecad328954b47446230ea11c9f5385f91586f36f950aa1c" + }, + { + "name": "base combo of three", + "chainId": 8453, + "clientData": { + "attention": "By signing this transaction, I agree to place a bet for an event on Azuro Protocol", + "affiliate": "0x1111111111111111111111111111111111111111", + "core": "0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d", + "expiresAt": 1792780300, + "chainId": 8453, + "relayerFeeAmount": "12345", + "isBetSponsored": false, + "isFeeSponsored": false, + "isSponsoredBetReturnable": false + }, + "amount": "1000000000000000", + "minOdds": "5123456789012", + "nonce": "42", + "bets": [ + { + "conditionId": "600610060000000000943019940000000000001406934671", + "outcomeId": "3" + }, + { + "conditionId": "600610060000000000028772180000000000002040246119", + "outcomeId": "1" + }, + { + "conditionId": "600610060000000000916514730000000000000737294024", + "outcomeId": "2" + } + ], + "domain": { + "name": "Live Betting", + "version": "1.0.0", + "chainId": 8453, + "verifyingContract": "0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d" + }, + "digest": "0x6f73db62c4a5e5dd266ced51cccbacf46154e771f50029918399f0023b244e2d", + "signature": "0xdc89a348d0a4f3cdd714d83dfbb0c7db13995a63f4e40c1eaae5ef8a5452b4517a44b138cd389bbddb78cf8d0712e9e1fa11bc90d3aa15bdb123537622f15a0d1b" + } + ], + "calls": { + "lp": "0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", + "core": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", + "paymaster": "0xeD5760fC2d2f6d363d73e576173e5e8Ca6A877e1", + "withdrawPayouts": "0xe8e3e87b000000000000000000000000f9548be470a4e130c90cea8b179fcd66d2972ac70000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000733f800000000000000000000000000000000000000000000000000000000000733f700000000000000000000000000000000000000000000000000000000000733f200000000000000000000000000000000000000000000000000000000000733f100000000000000000000000000000000000000000000000000000000000733f0", + "withdrawPayout": "0x161d0770000000000000000000000000f9548be470a4e130c90cea8b179fcd66d2972ac70000000000000000000000000000000000000000000000000000000000072419", + "viewPayout": "0x25aa820b000000000000000000000000f9548be470a4e130c90cea8b179fcd66d2972ac70000000000000000000000000000000000000000000000000000000000072419", + "paymasterWithdrawPayouts": "0x51eab807000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000002329", + "chains": { + "56": { + "lp": "0xa2db60D3DcecEc80d5D30c680a7b1e2018cD909f", + "paymaster": "0x3445f0C78Ce1141247c1BcB653FA28b65cD5FA89", + "azuroBet": "0x6Fb6a3A0D8fE1214AFFa18A7c3428078C7dd843d" + }, + "97": { + "lp": "0x431A0993d29eEb0fF7e3FE351A303eF72195431a", + "paymaster": "0x725ec0A9eC9dC993A86d0eFD7fD78929d226AbE7", + "azuroBet": "0xF7815889e5d0635A31eca34390b25d8D2cEeD902" + }, + "100": { + "lp": "0xeb7cDA87D00d677A6Dc73EB569723b0fA51D97E7", + "paymaster": "0x3Ebf1a2005f1a87862d61022a5BAC661994dD94e", + "azuroBet": "0xd7cd9342A14962C2dCB14569CB6fcA2759999089" + }, + "137": { + "lp": "0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", + "paymaster": "0xeD5760fC2d2f6d363d73e576173e5e8Ca6A877e1", + "azuroBet": "0x7A1c3FEf712753374C4DCe34254B96faF2B7265B" + }, + "8453": { + "lp": "0x1eD7368bc515E928A4007cEa61FB8a6F8863Af87", + "paymaster": "0x4a2BB4211cCF9b9eA6eF01D0a61448154ED19095", + "azuroBet": "0xF328404Dbc8c997d12dC55a1A179AF7F8cb7df90" + }, + "80002": { + "lp": "0x0a75395Ff15d9557424b632cEBCac448D66F9779", + "paymaster": "0x2d155962b708c931Fc695F674B415065E78D9F04", + "azuroBet": "0x4B75c071dFA5d537979E8b0615Bb97B6337dbFef" + }, + "84532": { + "lp": "0x4fE86EddCC22dc992b7994878169201Ec7ea50AB", + "paymaster": "0xC2577cdcF684C9756f2C81efdA001CbEFEf3Dd92", + "azuroBet": "0xD031C4BDd4b7c30e3725132109786B8E158A4c07" + }, + "88882": { + "lp": "0x431A0993d29eEb0fF7e3FE351A303eF72195431a", + "paymaster": "0x3df16FB8Dc28F63565AF2815E04a3368360FFd23", + "azuroBet": "0xF7815889e5d0635A31eca34390b25d8D2cEeD902" + }, + "88888": { + "lp": "0xEf6b12580301b04CD2551182C88623524B6e47b8", + "paymaster": "0xB3AE923721e8f3345654eFffC09f6F52afC21F4c", + "azuroBet": "0x3777B4F27F4B36f14454dbB1f79278bcba694B52" + } + }, + "onchain_withdrawPayouts_tx": "0x034542780bd4527a8e118e6d469498201efb55f930c496a326540de0585fcd28" + }, + "combo_odds": [ + { + "odds": [ + 1.58, + 1.58 + ], + "slippage": 0, + "minOdds": "2.530000000000" + }, + { + "odds": [ + 1.58, + 1.58 + ], + "slippage": 5, + "minOdds": "2.450000000000" + }, + { + "odds": [ + 1.51, + 1.55 + ], + "slippage": 0, + "minOdds": "2.370000000000" + }, + { + "odds": [ + 1.51, + 1.55 + ], + "slippage": 5, + "minOdds": "2.300000000000" + }, + { + "odds": [ + 2.04, + 2.6 + ], + "slippage": 0, + "minOdds": "5.380000000000" + }, + { + "odds": [ + 2.04, + 2.6 + ], + "slippage": 5, + "minOdds": "5.160000000000" + }, + { + "odds": [ + 1.36, + 2.77, + 6.83 + ], + "slippage": 0, + "minOdds": "26.390000000000" + }, + { + "odds": [ + 1.36, + 2.77, + 6.83 + ], + "slippage": 5, + "minOdds": "25.120000000000" + }, + { + "odds": [ + 1.01, + 1.07, + 1.04, + 1.1, + 1.06, + 1.14, + 1.08 + ], + "slippage": 0, + "minOdds": "1.810000000000" + }, + { + "odds": [ + 1.01, + 1.07, + 1.04, + 1.1, + 1.06, + 1.14, + 1.08 + ], + "slippage": 5, + "minOdds": "1.760000000000" + } + ] } diff --git a/tests/test_azuro.py b/tests/test_azuro.py index 310799e..67f0b4f 100644 --- a/tests/test_azuro.py +++ b/tests/test_azuro.py @@ -290,6 +290,17 @@ def test_a_larger_condition_needs_an_outcome(self): venue.fetch_market(f"azuro:{CORNERS['conditionId']}-1") assert venue.fetch_market(f"azuro:{THREE_WAY['conditionId']}-3").yes.label == "Trefelin FC (-1)" + def test_conditions_by_id_carry_their_game_and_combo_flag(self): + venue = venue_with(FEED) + found = venue.fetch_conditions([CORNERS["conditionId"], SETTLED_ID, "123", CORNERS["conditionId"]]) + assert list(found) == [CORNERS["conditionId"], SETTLED_ID] + assert found[CORNERS["conditionId"]]["isExpressForbidden"] is False + assert found[CORNERS["conditionId"]]["game"]["gameId"] == GAME["gameId"] + [(_, path, body), (_, batch_path, batch)] = venue.http.calls + assert path == "/market-manager/conditions-by-game-ids" + assert sorted(body["gameIds"]) == sorted([GAME["gameId"], FINISHED["gameId"]]) + assert batch_path == "/market-manager/condition-batch" and batch["conditionIds"] == ["123"] + def test_an_unknown_market(self): venue = venue_with({**FEED, "POST /market-manager/conditions-by-game-ids": {"conditions": []}, "POST /market-manager/condition-batch": {"conditions": []}}) diff --git a/tests/test_azuro_trading.py b/tests/test_azuro_trading.py index e29847e..865c99d 100644 --- a/tests/test_azuro_trading.py +++ b/tests/test_azuro_trading.py @@ -13,12 +13,16 @@ import pytest from eth_account import Account as EthAccount from eth_account.messages import _hash_eip191_message, encode_typed_data +from eth_account.typed_transactions import TypedTransaction +from hexbytes import HexBytes import synpath from synpath.azuro import CHAINS from synpath.trading import azuro_signing as sig from synpath.trading.azuro import ( - AzuroTrading, build_bet, check_request, fill_of_row, order_of_row, positions_of, relayer_fee_of, settlement_of_row, + AzuroTrading, ComboLeg, build_bet, build_combo, check_request, claim_calls, claimable_positions_of, + combo_quote_of, fill_of_row, freebet_share, order_of_row, positions_of, redeemable_of_bet, relayer_fee_of, + settlement_of_row, ) from synpath.trading.credentials import AzuroCredentials, CredentialsMissing, load_azuro, load_credentials from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected @@ -33,7 +37,8 @@ POLYGON = CHAINS["polygon"] CORNERS = "600610060000000000943019940000000000001802657415" THREE_WAY = "600610060000000000943019940000000000001406934671" -OUTCOMES = {CORNERS: ["1", "2"], THREE_WAY: ["1", "2", "3"]} +OTHER_GAME = "600610060000000000028666020000000000001624778125" +OUTCOMES = {CORNERS: ["1", "2"], THREE_WAY: ["1", "2", "3"], OTHER_GAME: ["1", "2"]} def request(**changes) -> OrderRequest: @@ -253,12 +258,17 @@ def sent(self, key: str) -> list[httpx.Request]: class Catalog: - def __init__(self): + def __init__(self, conditions=None, outcomes=None): self.asked: list[list[str]] = [] + self.conditions = conditions or {} + self.outcomes = {**OUTCOMES, **(outcomes or {})} def condition_outcomes(self, condition_ids): self.asked.append(list(condition_ids)) - return {c: OUTCOMES[c] for c in condition_ids if c in OUTCOMES} + return {c: self.outcomes[c] for c in condition_ids if c in self.outcomes} + + def fetch_conditions(self, condition_ids): + return {c: self.conditions[c] for c in condition_ids if c in self.conditions} def close(self): pass @@ -269,9 +279,13 @@ def close(self): BETTOR = f"GET /api/v1/public/bet/orders/bettor/{OWNER.address}" -def adapter(venue: Venue, **creds) -> AzuroTrading: +SUBGRAPH = "POST /subgraphs/name/azuro-protocol/azuro-api-polygon-v3" + + +def adapter(venue: Venue, *, catalog: Catalog | None = None, **creds) -> AzuroTrading: credentials = AzuroCredentials(private_key=KEY, rpc_url="https://rpc.test/", **creds) - return AzuroTrading(credentials, catalog=Catalog(), client=httpx.AsyncClient(transport=httpx.MockTransport(venue))) + return AzuroTrading(credentials, catalog=catalog or Catalog(), + client=httpx.AsyncClient(transport=httpx.MockTransport(venue))) def run(coro): @@ -362,7 +376,7 @@ async def go(): def test_the_bettors_lists(self): pending = row(id="p", state="Sent") settled = row(id="s", state="Settled", result="Won", payout=10.2, settledAt="2026-10-23T12:00:00.000Z") - venue = Venue(**{BETTOR: {"orders": [row(), pending, settled]}}) + venue = Venue(**{BETTOR: {"orders": [row(), pending, settled]}, SUBGRAPH: {"data": {"v3Bets": []}}}) async def go(): async with adapter(venue) as azuro: @@ -455,6 +469,442 @@ async def go(): assert body["clientBetData"]["clientData"]["core"] == CHAINS["base"].core +# --------------------------------------------------------------------------- +# Combos and claims +# --------------------------------------------------------------------------- + +BETS = load("azuro_redeemable_bets.json")["data"]["v3Bets"] +"""Real bets from the Polygon bets subgraph: two won singles waiting to be +claimed (467993, 467650), a won combo claimed (472005), a cancelled single +claimed (472056) and a 33-leg combo lost (472057).""" +BET = {b["tokenId"]: b for b in BETS} +CALLS = VECTORS["calls"] + + +def condition(condition_id: str, outcomes=("1", "2"), *, odds=("1.58", "2.3"), state="Active", forbidden=False, + hidden=False) -> dict: + """A condition as `conditions-by-game-ids` lists it.""" + return {"conditionId": condition_id, "state": state, "isExpressForbidden": forbidden, + "game": {"gameId": condition_id[4:26]}, + "outcomes": [{"outcomeId": o, "odds": p, "state": "Active", "hidden": hidden} for o, p in zip(outcomes, odds)]} + + +CONDITIONS = {CORNERS: condition(CORNERS), OTHER_GAME: condition(OTHER_GAME, odds=("1.51", "1.55")), + THREE_WAY: condition(THREE_WAY, ("1", "2", "3"), odds=("6.83", "4.95", "1.32"))} + + +class TestComboSigning: + @pytest.mark.parametrize("case", VECTORS["combos"], ids=lambda c: c["name"]) + def test_the_digest_and_signature_match_the_toolkit(self, case): + combo = sig.combo_bet(legs=[(b["conditionId"], b["outcomeId"]) for b in case["bets"]], + min_odds=int(case["minOdds"]), amount=int(case["amount"]), nonce=int(case["nonce"])) + assert "0x" + sig.combo_digest(case["clientData"], combo).hex() == case["digest"] + assert sig.sign_combo(WalletSigner(KEY), case["clientData"], combo) == case["signature"] + assert sig.combo_typed_data(case["clientData"], combo)["domain"] == case["domain"] + + @pytest.mark.parametrize("case", VECTORS["combos"], ids=lambda c: c["name"]) + def test_the_typed_data_is_what_a_wallet_signs(self, case): + combo = sig.combo_bet(legs=[(b["conditionId"], b["outcomeId"]) for b in case["bets"]], + min_odds=int(case["minOdds"]), amount=int(case["amount"]), nonce=int(case["nonce"])) + encoded = encode_typed_data(full_message=sig.combo_typed_data(case["clientData"], combo)) + assert "0x" + _hash_eip191_message(encoded).hex() == case["digest"] + assert EthAccount.recover_message(encoded, signature=case["signature"]) == OWNER.address + + def test_the_wire_body_is_create_combo_bets(self): + case = VECTORS["combos"][0] + combo = sig.combo_bet(legs=[(b["conditionId"], b["outcomeId"]) for b in case["bets"]], + min_odds=int(case["minOdds"]), amount=int(case["amount"]), nonce=int(case["nonce"])) + body = sig.wire_combo(POLYGON, bettor=OWNER.address, data=case["clientData"], combo=combo, + signature=case["signature"]) + assert body == { + "environment": "PolygonUSDT", "bettor": OWNER.address.lower(), "betOwner": OWNER.address.lower(), + "clientBetData": {"clientData": case["clientData"], "amount": case["amount"], "minOdds": case["minOdds"], + "nonce": case["nonce"], + "bets": [{"conditionId": b["conditionId"], "outcomeId": int(b["outcomeId"])} + for b in case["bets"]]}, + "bettorSignature": case["signature"], + } + + +class TestComboOdds: + @pytest.mark.parametrize("bet", [b for b in BETS if b["type"] == "Express"], ids=lambda b: b["tokenId"]) + def test_the_combo_rule_gives_the_odds_the_protocol_recorded(self, bet): + assert sig.combo_odds([s["odds"] for s in bet["selections"]]) == Decimal(bet["odds"]) + + @pytest.mark.parametrize("case", [c for c in VECTORS["combo_odds"] if c["slippage"] == 0], ids=str) + def test_cut_to_two_decimals_it_is_the_toolkits_calc_min_odds(self, case): + odds = sig.combo_odds([str(o) for o in case["odds"]]) + assert odds.quantize(Decimal("0.01"), rounding="ROUND_DOWN") == Decimal(case["minOdds"]) + + def test_odds_that_take_no_bets(self): + for bad in ([], ["1"], ["1.5", "0.9"]): + with pytest.raises(InvalidOrder): + sig.combo_odds(bad) + + +class TestClaimCalldata: + def test_withdraw_payouts_is_the_toolkits_and_the_chains(self): + data = sig.withdraw_payouts_calldata(CALLS["core"], [472056, 472055, 472050, 472049, 472048]) + assert data == CALLS["withdrawPayouts"] + # The same five bets were claimed on Polygon by this exact input, + # transaction 0x034542780bd4527a8e118e6d469498201efb55f930c496a326540de0585fcd28. + assert data.startswith("0xe8e3e87b") + + def test_the_other_calls(self): + assert sig.withdraw_payout_calldata(CALLS["core"], 467993) == CALLS["withdrawPayout"] + assert sig.view_payout_calldata(CALLS["core"], "467993") == CALLS["viewPayout"] + assert sig.paymaster_withdraw_payouts_calldata([9001]) == CALLS["paymasterWithdrawPayouts"] + + def test_the_pools_are_the_toolkits(self): + for chain in CHAINS.values(): + assert chain.lp == CALLS["chains"][str(chain.chain_id)]["lp"] + + +class TestCombos: + LEGS = [ComboLeg(f"azuro:{CORNERS}"), ComboLeg(f"azuro:{OTHER_GAME}", Side.SELL)] + SELECTIONS = [(CORNERS, "1"), (OTHER_GAME, "2")] + + def test_a_combo_is_signed_with_a_worst_price_for_the_whole(self): + body, combo = build_combo(self.SELECTIONS, chain=POLYGON, signer=WalletSigner(KEY), quantity=Decimal("10"), + price=Decimal("0.4"), relayer_fee=12345, nonce=9, now=1_800_000_000) + assert combo == {"minOdds": str(25 * 10 ** 11), "amount": "4000000", "nonce": "9", + "bets": [{"conditionId": CORNERS, "outcomeId": "1"}, {"conditionId": OTHER_GAME, "outcomeId": "2"}]} + data = body["clientBetData"]["clientData"] + assert data["expiresAt"] == 1_800_000_300 and data["relayerFeeAmount"] == "12345" + assert body["clientBetData"]["bets"][1] == {"conditionId": OTHER_GAME, "outcomeId": 2} + encoded = encode_typed_data(full_message=sig.combo_typed_data(data, combo)) + assert EthAccount.recover_message(encoded, signature=body["bettorSignature"]) == OWNER.address + + def test_one_leg_is_no_combo(self): + with pytest.raises(InvalidOrder, match="at least 2"): + build_combo(self.SELECTIONS[:1], chain=POLYGON, signer=WalletSigner(KEY), quantity=Decimal("1"), + price=Decimal("0.5"), relayer_fee=1, nonce=1) + + def test_an_eligible_quote(self): + quote = combo_quote_of(self.LEGS, self.SELECTIONS, CONDITIONS, {"minBet": 1, "maxBet": 200, "maxPayout": "900"}, + currency="USDT", stake=Decimal("5")) + # 1.58 / 0.99 -> 1.60, 1.55 / 0.99 -> 1.57; 1.60 * 1.57 * 0.99 + assert quote.eligible and quote.odds == Decimal("2.48688") and quote.price == 1 / Decimal("2.48688") + assert (quote.min_bet, quote.max_bet, quote.max_payout) == (1, 200, 900) + assert [leg["odds"] for leg in quote.legs] == [Decimal("1.58"), Decimal("1.55")] + assert quote.legs[1]["market_id"] == f"azuro:{OTHER_GAME}" and quote.legs[1]["side"] == Side.SELL + + @pytest.mark.parametrize("change, problem", [ + (lambda c: {**c, CORNERS: condition(CORNERS, forbidden=True)}, "not offered in combos"), + (lambda c: {**c, CORNERS: condition(CORNERS, state="Stopped")}, "is Stopped"), + (lambda c: {**c, CORNERS: condition(CORNERS, hidden=True)}, "not taking bets"), + (lambda c: {k: v for k, v in c.items() if k != CORNERS}, "no condition"), + ]) + def test_what_stops_a_combo(self, change, problem): + quote = combo_quote_of(self.LEGS, self.SELECTIONS, change(CONDITIONS), {"maxBet": 10}, currency="USDT") + assert not quote.eligible and any(problem in p for p in quote.problems) + + def test_legs_on_one_game(self): + legs = [ComboLeg(f"azuro:{CORNERS}"), ComboLeg(f"azuro:{THREE_WAY}-3")] + quote = combo_quote_of(legs, [(CORNERS, "1"), (THREE_WAY, "3")], CONDITIONS, None, currency="USDT") + assert quote.problems == ["game 1006000000000094301994 is in the combo twice; legs must be on different games"] + twice = combo_quote_of(self.LEGS[:1] * 2, [(CORNERS, "1"), (CORNERS, "2")], CONDITIONS, None, currency="USDT") + assert any("in the combo twice" in p for p in twice.problems) + alone = combo_quote_of(self.LEGS[:1], self.SELECTIONS[:1], CONDITIONS, None, currency="USDT") + assert alone.problems == ["a combo needs at least 2 legs"] + + def test_the_stake_against_the_limits(self): + def problems(limits, stake): + return combo_quote_of(self.LEGS, self.SELECTIONS, CONDITIONS, limits, currency="USDT", + stake=Decimal(stake)).problems + assert problems({"maxBet": 0}, "1") == ["the pool takes nothing on this combo now"] + assert problems({"maxBet": 3}, "5") == ["a stake of 5 is above the pool's maximum of 3"] + assert problems({"minBet": 1, "maxBet": 3}, "0.5") == ["a stake of 0.5 is below the pool's minimum of 1"] + + +class TestComboRecords: + def combo(self, **changes): + legs = [row()["conditions"][0], {**row()["conditions"][0], "conditionId": OTHER_GAME, "outcomeId": 2, + "price": "1.55", "potentialAmount": None}] + return row(betType="COMBO", odds=2.48688, conditions=legs, **changes) + + def test_a_combo_is_a_buy_of_the_whole_with_its_legs(self): + order = order_of_row(self.combo(), outcomes=OUTCOMES[CORNERS], legs_outcomes=OUTCOMES) + assert order.info["combo"] and order.side == Side.BUY and order.market_id == f"azuro:{CORNERS}" + assert order.price == 1 / Decimal("2.48688") and order.quantity == Decimal("5") * Decimal("2.48688") + assert [(leg["market_id"], leg["side"]) for leg in order.info["legs"]] == [ + (f"azuro:{CORNERS}", Side.BUY), (f"azuro:{OTHER_GAME}", Side.SELL)] + + def test_a_settled_combo_and_its_claim(self): + won = settlement_of_row(self.combo(state="Settled", result="Won", payout=12.4344), outcomes=OUTCOMES[CORNERS], + legs_outcomes=OUTCOMES) + assert won.info["combo"] and won.won and won.payout == Decimal("12.4344") and won.pnl == Decimal("7.4344") + assert won.info["claimed"] is False and won.info["claimable"] == Decimal("12.4344") + claimed = settlement_of_row(self.combo(state="Settled", result="Won", payout=12.4344, + redeemedAt="2026-10-24T00:00:00.000Z", + meta={"isRedeemed": True, "redeemedTxHash": "0x" + "cd" * 32}), + outcomes=OUTCOMES[CORNERS]) + assert claimed.info["claimed"] is True and claimed.info["claimable"] == 0 + assert claimed.info["claim_transaction"] == "0x" + "cd" * 32 + lost = settlement_of_row(self.combo(state="Settled", result="Lost"), outcomes=OUTCOMES[CORNERS]) + assert lost.won is False and lost.info["claimed"] is None and lost.info["claimable"] == 0 + + def test_a_cancelled_single_returns_its_stake_to_claim(self): + void = settlement_of_row(row(state="Settled", result="Canceled"), outcomes=OUTCOMES[CORNERS]) + assert void.payout == Decimal("5") and void.info["claimable"] == Decimal("5") + cashed = settlement_of_row(row(state="Settled", result="Won", payout=10.2, meta={"isCashedOut": True}), + outcomes=OUTCOMES[CORNERS]) + assert cashed.info["claimable"] == 0 + + +class TestRedeemable: + def test_won_bets_waiting_are_claims(self): + claim = redeemable_of_bet(BET["467993"]) + assert claim["bet_id"] == "467993" and claim["result"] == "won" and not claim["combo"] + assert (claim["amount"], claim["raw_amount"], claim["stake"]) == (Decimal("5.84"), 5_840_000, Decimal("2")) + assert (claim["core"], claim["lp"]) == (POLYGON.core.lower(), POLYGON.lp.lower()) + assert claim["legs"] == [("600610060000000000901243060000000000000131378013", "3")] + + def test_claimed_and_lost_bets_are_not(self): + assert [redeemable_of_bet(BET[i]) for i in ("472005", "472056", "472057")] == [None, None, None] + + def test_a_cancelled_bet_returns_its_stake(self): + waiting = {**BET["472056"], "isRedeemable": True, "isRedeemed": False, "rawPayout": None} + claim = redeemable_of_bet(waiting) + assert claim["result"] == "canceled" and claim["amount"] == Decimal("10") + + def test_a_won_combo(self): + waiting = {**BET["472005"], "isRedeemable": True, "isRedeemed": False} + claim = redeemable_of_bet(waiting) + assert claim["combo"] and claim["amount"] == Decimal("17.48736") and len(claim["legs"]) == 2 + + def test_a_free_bets_share(self): + assert freebet_share(30, 10, None) == 20 and freebet_share(30, 10, False) == 30 + assert freebet_share(10, 10, True) == 0 + free = {**BET["467993"], "freebetId": "77", "paymasterContractAddress": CALLS["paymaster"], + "isFreebetAmountReturnable": True} + assert redeemable_of_bet(free)["amount"] == Decimal("3.84") + + def test_claims_are_batched_per_pool(self): + claims = [redeemable_of_bet(BET["467993"]), redeemable_of_bet(BET["467650"])] + [call] = claim_calls(claims, chain=POLYGON) + assert call["to"] == POLYGON.lp and call["bet_ids"] == ["467993", "467650"] + assert call["data"] == sig.withdraw_payouts_calldata(POLYGON.core, [467993, 467650]) + assert call["raw_amount"] == 5_840_000 + 1_530_000 + assert [c["bet_ids"] for c in claim_calls(claims, chain=POLYGON, batch=1)] == [["467993"], ["467650"]] + + def test_free_bets_and_legacy_pools_claim_one_each(self): + free = redeemable_of_bet({**BET["467993"], "freebetId": "9001", "paymasterContractAddress": CALLS["paymaster"]}) + legacy = {**redeemable_of_bet(BET["467650"]), "lp": "0x" + "12" * 20} + paid, old = claim_calls([free, legacy], chain=POLYGON) + assert (paid["to"], paid["data"]) == (CALLS["paymaster"], CALLS["paymasterWithdrawPayouts"]) + assert old["to"] == "0x" + "12" * 20 and old["data"] == sig.withdraw_payout_calldata(POLYGON.core, 467650) + with pytest.raises(InvalidOrder, match="paymaster"): + claim_calls([{**free, "paymaster": None}], chain=POLYGON) + + def test_claimable_positions(self): + claims = [redeemable_of_bet(BET["467993"])] + three_way = "600610060000000000901243060000000000000131378013" + [position] = claimable_positions_of(claims, outcomes={three_way: ["1", "2", "3"]}, decimals=6) + assert position.market_id == f"azuro:{three_way}-3" and position.resolved and position.won + assert position.redeemable == position.payout == Decimal("5.84") and position.contracts == Decimal("5.84") + assert position.margin == Decimal("2") and position.info["bet_ids"] == ["467993"] + + +def rpc(result): + return {"jsonrpc": "2.0", "id": 1, "result": result} + + +def sent_transactions(venue: Venue) -> list[dict]: + raws = [json.loads(r.content)["params"][0] for r in venue.requests + if r.url.host == "rpc.test" and json.loads(r.content)["method"] == "eth_sendRawTransaction"] + return [TypedTransaction.from_bytes(HexBytes(raw)).as_dict() for raw in raws] + + +class TestComboAdapter: + LEGS = [f"azuro:{CORNERS}", (f"azuro:{OTHER_GAME}", "sell")] + CALC = "POST /api/v1/public/bet/calculation" + COMBO = "POST /api/v1/public/bet/orders/combo" + + def venue(self, **more): + return Venue(**{GAS: {"relayerFeeAmount": "20000"}, self.CALC: {"response": {"minBet": 1, "maxBet": 100, + "maxPayout": "1000"}}, + self.COMBO: {"id": "0xf39f_2", "state": "Created"}, **more}) + + def test_a_combo_is_quoted(self): + venue = self.venue() + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + return await azuro.fetch_combo_quote(self.LEGS, stake=Decimal("4")) + + quote = run(go()) + assert quote.eligible and quote.odds == Decimal("2.48688") and quote.currency == "USDT" + asked = json.loads(venue.sent(self.CALC)[0].content) + assert asked == {"bets": [{"conditionId": CORNERS, "outcomeId": 1}, {"conditionId": OTHER_GAME, "outcomeId": 2}], + "environment": "PolygonUSDT", "wallet": OWNER.address} + + def test_a_combo_is_signed_and_handed_to_the_relayer(self): + venue = self.venue() + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + return await azuro.create_combo_order(self.LEGS, quantity=Decimal("10"), price=Decimal("0.42")) + + order = run(go()) + body = json.loads(venue.sent(self.COMBO)[0].content) + bet = body["clientBetData"] + assert bet["amount"] == "4200000" and bet["minOdds"] == str(sig.min_odds(Decimal("0.42"))) + assert bet["bets"] == [{"conditionId": CORNERS, "outcomeId": 1}, {"conditionId": OTHER_GAME, "outcomeId": 2}] + assert bet["clientData"]["relayerFeeAmount"] == "20000" + combo = sig.combo_bet(legs=[(CORNERS, "1"), (OTHER_GAME, "2")], min_odds=int(bet["minOdds"]), + amount=int(bet["amount"]), nonce=int(bet["nonce"])) + encoded = encode_typed_data(full_message=sig.combo_typed_data(bet["clientData"], combo)) + assert EthAccount.recover_message(encoded, signature=body["bettorSignature"]) == OWNER.address + assert (order.id, order.status, order.side, order.cost) == ("0xf39f_2", OrderStatus.PENDING, Side.BUY, Decimal("4.2")) + assert order.info["combo"] and [leg["side"] for leg in order.info["legs"]] == [Side.BUY, Side.SELL] + assert order.quantity == Decimal("10") + + def test_a_combo_that_would_be_refused_is_never_signed(self): + venue = self.venue() + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + await azuro.create_combo_order([f"azuro:{CORNERS}", f"azuro:{THREE_WAY}-3"], quantity=Decimal("10"), + price=Decimal("0.1")) + + with pytest.raises(InvalidOrder, match="different games"): + run(go()) + assert not venue.sent(self.COMBO) + + def test_a_refused_combo_is_typed(self): + venue = self.venue(**{self.COMBO: httpx.Response(400, json={"errorMessage": "Insufficient balance"})}) + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + await azuro.create_combo_order(self.LEGS, quantity=Decimal("10"), price=Decimal("0.42"), check=False) + + with pytest.raises(InsufficientFunds): + run(go()) + assert not venue.sent(self.CALC), "check=False reads no quote" + + +class TestClaimAdapter: + def venue(self, bets, **rpcs): + answers = { + SUBGRAPH: {"data": {"v3Bets": bets}}, + "RPC eth_estimateGas": rpc(hex(150_000)), "RPC eth_getTransactionCount": rpc("0x7"), + "RPC eth_gasPrice": rpc(hex(30 * 10 ** 9)), "RPC eth_maxPriorityFeePerGas": rpc(hex(10 ** 9)), + "RPC eth_sendRawTransaction": lambda req: rpc("0x" + "%064x" % len(req.content)), + "RPC eth_getTransactionReceipt": rpc({"status": "0x1"}), + } + return Venue(**{**answers, **{f"RPC {k}": v for k, v in rpcs.items()}}) + + def test_the_redeemable_bets_are_read_from_the_subgraph(self): + venue = self.venue(BETS) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.fetch_redeemable_bets() + + bets = run(go()) + assert [b["bet_id"] for b in bets] == ["467993", "467650"] + asked = json.loads(venue.sent(SUBGRAPH)[0].content)["variables"] + assert asked["where"] == {"actor": OWNER.address.lower(), "isRedeemable": True, "isCashedOut": False} + assert asked["skip"] == 0 + + def test_claims_go_in_one_transaction_from_the_wallet(self): + venue = self.venue(BETS) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.claim_winnings() + + result = run(go()) + assert result["bet_ids"] == ["467993", "467650"] and result["amount"] == Decimal("7.37") + [entry] = result["transactions"] + assert entry["to"] == POLYGON.lp and entry["receipt"] == {"status": "0x1"} + [estimate] = [json.loads(r.content)["params"][0] for r in venue.requests + if r.url.host == "rpc.test" and json.loads(r.content)["method"] == "eth_estimateGas"] + assert estimate == {"from": OWNER.address, "to": POLYGON.lp, + "data": sig.withdraw_payouts_calldata(POLYGON.core, [467993, 467650])} + [tx] = sent_transactions(venue) + assert tx["nonce"] == 7 and tx["to"] == HexBytes(POLYGON.lp) and tx["chainId"] == 137 + assert "0x" + bytes(tx["data"]).hex() == estimate["data"] and tx["gas"] == 180_000 + + def test_one_bet_and_batches(self): + venue = self.venue(BETS) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.redeem(["467650"], wait=False), await azuro.redeem(batch=1, wait=False) + + one, both = run(go()) + assert one["bet_ids"] == ["467650"] and "receipt" not in one["transactions"][0] + assert [t["bet_ids"] for t in both["transactions"]] == [["467993"], ["467650"]] + assert [t["nonce"] for t in sent_transactions(venue)] == [7, 7, 8] + + def test_a_bet_with_nothing_to_claim(self): + async def go(): + async with adapter(self.venue(BETS)) as azuro: + await azuro.redeem(["472005"]) + + with pytest.raises(InvalidOrder, match="nothing to claim on bet 472005"): + run(go()) + + def test_nothing_to_claim_sends_nothing(self): + venue = self.venue([]) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.redeem() + + assert run(go()) == {"transactions": [], "bet_ids": [], "amount": 0, "currency": "USDT"} + assert not [r for r in venue.requests if r.url.host == "rpc.test"] + + def test_a_claim_that_would_revert_is_refused_before_sending(self): + paid = {"jsonrpc": "2.0", "id": 1, "error": {"code": 3, "message": "execution reverted", "data": "0xd70a0e30"}} + venue = self.venue(BETS, eth_estimateGas=paid) + + async def go(): + async with adapter(venue) as azuro: + await azuro.redeem() + + with pytest.raises(OrderRejected, match="AlreadyPaid") as caught: + run(go()) + assert caught.value.reason == "alreadypaid" and not sent_transactions(venue) + + def test_a_reverted_claim(self): + venue = self.venue(BETS, eth_getTransactionReceipt=rpc({"status": "0x0"})) + + async def go(): + async with adapter(venue) as azuro: + await azuro.redeem() + + with pytest.raises(OrderRejected, match="reverted"): + run(go()) + + def test_what_a_claim_pays_now_is_read_on_chain(self): + def call(req): + target = json.loads(req.content)["params"][0] + assert target == {"to": POLYGON.lp, "data": sig.view_payout_calldata(POLYGON.core, 467993)} + return rpc(hex(5_840_000)) + + async def go(): + async with adapter(self.venue([], eth_call=call)) as azuro: + return await azuro.fetch_claimable(467993) + + assert run(go()) == Decimal("5.84") + + def test_positions_carry_what_waits_to_be_claimed(self): + three_way = "600610060000000000901243060000000000000131378013" + venue = self.venue(BETS[:1]) + venue.answers[BETTOR] = {"orders": [row()]} + + async def go(): + async with adapter(venue, catalog=Catalog(outcomes={three_way: ["1", "2", "3"]})) as azuro: + return await azuro.fetch_positions() + + open_position, waiting = run(go()) + assert open_position.market_id == f"azuro:{CORNERS}" and not open_position.resolved + assert waiting.market_id == f"azuro:{three_way}-3" and waiting.resolved and waiting.redeemable == Decimal("5.84") + + class TestCredentials: def test_a_key_alone_bets_on_polygon(self): creds = load_azuro({"AZURO_PRIVATE_KEY": KEY[2:]}) From 9f0e779ef25be60e9313bc607cf422bdab17c13b Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 15:37:45 +0000 Subject: [PATCH 18/19] fix: combine venue follow-ups README: Novig's parlay RFQ is now supported for liquidity providers. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d7529e..614bd03 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Gemini Predictions is Gemini Titan's CFTC-registered event-contract exchange. Ea Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting logs in with your own Smarkets username and password, and placing orders needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). -Novig, a US sports exchange, lists two-outcome markets (a moneyline, a spread, a total, a Yes/No prop), each one yes/no market, `novig:01a1577c-d666-7e81-852e-68bed1446801`, under its game as the event. Its catalog, books and trades are public and need no key. Trading signs each request with a subaccount's key; Production trades real money for verified US customers outside Arizona, Michigan and Nevada, and Paper (`NOVIG_ENV=paper`) is the same API with play money. Novig's parlay RFQ, for registered liquidity providers, is not supported yet. See [capabilities](docs/capabilities.md#novig). +Novig, a US sports exchange, lists two-outcome markets (a moneyline, a spread, a total, a Yes/No prop), each one yes/no market, `novig:01a1577c-d666-7e81-852e-68bed1446801`, under its game as the event. Its catalog, books and trades are public and need no key. Trading signs each request with a subaccount's key; Production trades real money for verified US customers outside Arizona, Michigan and Nevada, and Paper (`NOVIG_ENV=paper`) is the same API with play money. Novig's parlay RFQ is supported from the liquidity provider's side: `NovigPricer` prices and last-looks parlays for a registered LP account ([trading](docs/trading.md#novig-parlay-rfq)). See [capabilities](docs/capabilities.md#novig). Matchbook, a betting exchange licensed in the UK and Alderney, is read the same way: each runner is a yes/no market, `matchbook:--`, and the Matchbook market holding the runners is the event. Its market data needs no account; betting logs in with your own Matchbook username and password. Every GET is chargeable on Matchbook's API plans (£100 per million a month), and its terms allow viewing prices and betting on your own account only. See [capabilities](docs/capabilities.md#matchbook). From 6b55c9e33a8c8fdcf241d25c701c20e9d170845a Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 15:39:05 +0000 Subject: [PATCH 19/19] chore: prepare synpath 0.11.0 release Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- synpath/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e441b5d..8cc5da4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ backend-path = ["build_backend"] [project] name = "synpath" -version = "0.10.0" +version = "0.11.0" description = "One API for 16 prediction markets and sports exchanges: Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Myriad, Gemini, Betfair, Smarkets, Novig, Matchbook, ProphetX and Azuro. Market data, streaming, self-hosted trading and smart order routing." readme = "README.md" requires-python = ">=3.10" diff --git a/synpath/__init__.py b/synpath/__init__.py index 50c85ad..24daa26 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -88,7 +88,7 @@ TradesRangeResponse, ) -__version__ = "0.10.0" +__version__ = "0.11.0" exchanges: dict[str, type[Exchange]] = { "kalshi": Kalshi,