diff --git a/.env.example b/.env.example index 0934e99..207b78e 100644 --- a/.env.example +++ b/.env.example @@ -114,3 +114,80 @@ 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= + +# 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= + +# 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= + +# 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= + +# 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= + +# 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..40c5d11 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 16 venues (Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Myriad and Gemini, and the sports betting venues Betfair, Smarkets, Novig, Matchbook, ProphetX and Azuro): 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,34 @@ 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 | +| | [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 | +| | [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). +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). +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). + +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). + +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). + +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). + ## 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, Myriad and Gemini, and the sports betting venues Betfair, Smarkets, Novig, Matchbook, ProphetX and Azuro, 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 +153,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', 'novig', 'matchbook', 'prophetx', 'azuro', 'myriad', 'gemini'] (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..4de4820 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; Novig one request each; Matchbook 20 events a request, every market of each; ProphetX 20 events a request; Azuro reads each game's conditions once, ten games a request; Myriad 100 a request per network and kind of market; 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"` | -| `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), on Matchbook (20 Matchbook events, every runner 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,106 @@ 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 +``` + +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 +``` + +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 +``` + +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 +``` + +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 +``` + +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 +304,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, Novig, Matchbook, ProphetX, Azuro 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 +356,21 @@ 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). Matchbook +lists only open events with their markets and raises `NotSupported` for the +rest (read a closed market by id). ProphetX lists +only games not started or live, so it raises for everything but `open`. Azuro lists +`open` games only and raises for the rest; a settled market is read by id. 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 Novig a market is a two-outcome market, `novig:` (`novig:01a1577c-d666-7e81-852e-68bed1446801`), and its event the game, `novig:`. On Matchbook a market is a runner, `matchbook:--` (`matchbook:34569494493900023-34569495835700023-34569495836701023`), and its event the Matchbook market, `matchbook:-`. 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`. 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. 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:`. 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..4a19cb2 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` | `matchbook` | `prophetx` | `azuro` | `myriad` | `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, 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 | 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 | 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 | 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 | 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 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 game (or futures question), its markets read one request per event | yes, one per Matchbook market (all its runners); the fixture or race is `info["matchbook_event"]` | yes, one per game, one tournament a page | yes, one per game, every condition's markets | yes, the venue's events; every other market is its own 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, open or closed; settled from history with a key | yes, open or closed; a closed market costs three more reads (Matchbook lists its runners separately) | yes, while the game is listed (not started or live) | yes, open or settled | yes, open or settled, by id or slug | 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, top 20 levels per outcome: bids on the YES outcome are YES bids, bids on the NO outcome YES asks; NO mirrored | yes, the runner's prices: lay prices are YES bids, back prices YES asks, three levels a side by default; NO mirrored | yes, ten best levels a side: side one's offers are YES asks, side two's YES bids; NO mirrored | partial: no book; the pool's quote as one level a side, sized by its limits (`/bet/calculation`); 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, 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 | yes, twenty Matchbook events a request, every runner of each | yes, twenty events a request | yes, up to three requests per market | yes, one request per market | 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 | no: Matchbook publishes no trades, only the volume matched per runner | no: fills are broadcast on the WebSocket only (`watch_trades`) | yes, every single bet, from the bets subgraph | yes: on a book one trade per taker fill; on a pool its buys and sells | 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: built from the public tape, with volume; at most ten pages of 500 prints back | no | no: no price history | partial: built from the 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 | 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 | 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) | yes, 2% on net market winnings (UK and Ireland; 4% elsewhere, pass `commission=`) | no: the API documentation publishes no fee rate; each order and fill carries its fee | yes, nothing on top of the odds (`pool_margin`); the relayer's flat fee per bet is on `fetch_fee_estimate` | 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, 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 | partial: no venue search; event descriptions matched here over the open catalog | no: the events API has no text search; filter by `sport` or `tag` | partial: no venue search; event, tournament and competitor names matched here over the open games | yes, the feed's game search | yes, at the venue (titles, descriptions, outcomes) | 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 request per market | yes, twenty events a request, every market of each | yes, twenty events a request | yes, each game's conditions once, ten games a request | yes, a hundred a request per network and kind of market | 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 catalog lists newest first | no: the venue lists by start time only | no: the venue lists in its own order | no: the feed orders games, not markets | yes, at the venue (24-hour volume, liquidity, publication time) | no: the listing takes no sort | +| `match_market` / `match_event` | no | no | no | no | no | 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,11 +177,329 @@ 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 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. + +### 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. + +### 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. + +### 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. + +### 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 +527,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` | `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 | 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 +576,16 @@ 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 | +| `MatchbookMarketStream` | partial: polled from REST, every 5 s by default | partial: polled | no: no public trades | partial: polled | 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 | +| `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 | +| `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 | +| `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..b1aedf9 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -42,7 +42,17 @@ 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 | +| `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 | +| `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 | +| `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 | +| `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`, @@ -64,11 +74,17 @@ 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` | | 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` | +| 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` | +| Azuro | nothing | `AZURO_CHAIN` picks the pool when trading | +| 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 @@ -171,6 +187,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 @@ -189,6 +217,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 @@ -204,6 +247,95 @@ 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`. +- *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. +- *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. +- *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..3b8580c 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -24,8 +24,8 @@ 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. +stacks: Ed25519 or RSA-PSS for Kalshi, EIP-712 for Polymarket, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Azuro and Myriad, +Ed25519 and private-key JWT for Polymarket US, Ed25519 or P-256 request signing (NOVIG-V3) for Novig; ProphetX signs nothing (a key pair buys a bearer token), 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,12 @@ 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 | +| 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 | +| 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 | +| 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 @@ -94,7 +100,13 @@ 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) | +| 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) | +| 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`) @@ -108,6 +120,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 @@ -573,3 +612,273 @@ 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. + +## 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. + +## 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. + +## 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. + +## 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/pyproject.toml b/pyproject.toml index 033d26c..e441b5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ 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." +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" license = { text = "MIT" } diff --git a/synpath/__init__.py b/synpath/__init__.py index b43731a..ff88c20 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -47,8 +47,14 @@ 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 .novig import Novig +from .matchbook import Matchbook +from .prophetx import ProphetX +from .azuro import Azuro +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 @@ -82,7 +88,7 @@ TradesRangeResponse, ) -__version__ = "0.9.0" +__version__ = "0.10.0" exchanges: dict[str, type[Exchange]] = { "kalshi": Kalshi, @@ -95,6 +101,12 @@ "sx_bet": SxBet, "betfair": Betfair, "smarkets": Smarkets, + "novig": Novig, + "matchbook": Matchbook, + "prophetx": ProphetX, + "azuro": Azuro, + "myriad": Myriad, + "gemini": Gemini, } """Every venue this library speaks, by id.""" @@ -115,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, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, MatchbookTrading, ProphetXTrading, AzuroTrading, MyriadTrading, GeminiTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -145,7 +157,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", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -167,7 +179,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", "Matchbook", "ProphetX", "Azuro", "Myriad", "Gemini", "Exchange", "exchange", "exchanges", "Capability", "HttpClient", "RateLimiter", "Market", "Event", "Outcome", "Quote", "MarketStats", "OrderBook", "OrderLevel", "Trade", "Candle", "FeeSchedule", "Series", "Page", @@ -184,7 +196,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", "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/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..a19ec85 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -383,7 +383,43 @@ 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] + + +def _matchbook(creds: Credentials) -> TradingExchange: + from .trading.matchbook import MatchbookTrading + return MatchbookTrading(creds) # type: ignore[arg-type] + + +def _prophetx(creds: Credentials) -> TradingExchange: + from .trading.prophetx import ProphetXTrading + return ProphetXTrading(creds) # type: ignore[arg-type] + + +def _azuro(creds: Credentials) -> TradingExchange: + from .trading.azuro import AzuroTrading + return AzuroTrading(creds) # type: ignore[arg-type] + + +def _myriad(creds: Credentials) -> TradingExchange: + from .trading.myriad import MyriadTrading + return MyriadTrading(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, + "novig": _novig, + "matchbook": _matchbook, + "prophetx": _prophetx, + "azuro": _azuro, + "myriad": _myriad, + "gemini": _gemini, } diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..352385a 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -65,6 +65,12 @@ "predict_fun": ("..trading.predict_fun", "PredictFunTrading"), "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), + "novig": ("..trading.novig", "NovigTrading"), + "matchbook": ("..trading.matchbook", "MatchbookTrading"), + "prophetx": ("..trading.prophetx", "ProphetXTrading"), + "azuro": ("..trading.azuro", "AzuroTrading"), + "myriad": ("..trading.myriad", "MyriadTrading"), + "gemini": ("..trading.gemini", "GeminiTrading"), } diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..a4672bf 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -153,17 +153,74 @@ 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. 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 # 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, + ) + 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) + 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), + ) + 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/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..b9f99af 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -4,8 +4,15 @@ 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`, `novig:01a1577c-d666-7e81-852e-68bed1446801`, +`matchbook:34569494493900023-34569495835700023-34569495836701023`, +`prophetx:44285780-90de769f4f4977a04446ed203e957351`, +`azuro:600610060000000000943019940000000000001802657415`, `myriad:56-2385` (a +network and a market number, which Myriad's API takes together; +`myriad:56-amm-2385` for an AMM market), +`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` reads the prefix and hands the call to that venue's adapter. Every venue @@ -16,7 +23,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", "matchbook", "prophetx", "azuro", "myriad", "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/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/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/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/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/server/serve.py b/synpath/server/serve.py index 6743665..462b90e 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", "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 aad1025..a7a2365 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -81,12 +81,31 @@ def __getattr__(name: str): if name == "SxBetTrading": from .sx_bet import SxBetTrading return SxBetTrading + if name == "NovigTrading": + from .novig import NovigTrading + return NovigTrading + if name == "MatchbookTrading": + from .matchbook import MatchbookTrading + return MatchbookTrading + if name == "ProphetXTrading": + from .prophetx import ProphetXTrading + return ProphetXTrading + if name == "AzuroTrading": + from .azuro import AzuroTrading + return AzuroTrading + if name == "MyriadTrading": + from .myriad import MyriadTrading + return MyriadTrading + 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", "NovigTrading", + "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 ab2f240..1746aae 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -29,6 +29,26 @@ 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 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: @@ -45,6 +65,21 @@ 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 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 == "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] @@ -52,11 +87,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", "prophetx", "azuro", "myriad") 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}" @@ -86,14 +123,31 @@ 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 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 == "azuro": + stage = creds.chain + affiliate = f", affiliate {creds.affiliate}" if creds.affiliate else "" + identity = f"wallet {creds.address}{affiliate}" + 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" + 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/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..6c31691 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,11 +41,31 @@ 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 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) + Matchbook MATCHBOOK_USERNAME, MATCHBOOK_PASSWORD: betting logs in; + market data needs no login + 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 + Azuro AZURO_PRIVATE_KEY, and optionally AZURO_CHAIN (polygon, + the default, base, polygon_amoy, base_sepolia), + AZURO_AFFILIATE, AZURO_RPC_URL + 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 @@ -316,6 +337,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 @@ -357,10 +417,139 @@ 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] + + +@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] + + +@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] + + +@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] + + +@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 | NovigCredentials | MatchbookCredentials | ProphetXCredentials | AzuroCredentials | MyriadCredentials | GeminiCredentials ) ENV_NAMES: dict[str, tuple[str, ...]] = { @@ -389,6 +578,15 @@ 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", + ), + "matchbook": ("MATCHBOOK_USERNAME", "MATCHBOOK_PASSWORD"), + "prophetx": ("PROPHETX_ACCESS_KEY", "PROPHETX_SECRET_KEY", "PROPHETX_AFFILIATE_KEY", "PROPHETX_SANDBOX"), + "azuro": ("AZURO_PRIVATE_KEY", "AZURO_CHAIN", "AZURO_AFFILIATE", "AZURO_RPC_URL"), + "myriad": ("MYRIAD_PRIVATE_KEY", "MYRIAD_API_KEY", "MYRIAD_API_SECRET", "MYRIAD_ACCOUNT_ADDRESS", "MYRIAD_RPC_URL"), + "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.""" @@ -638,6 +836,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.""" @@ -685,6 +912,102 @@ 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] + ) + + +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) + + +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) + + +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, + ) + + +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 +1020,12 @@ def load_smarkets(env: Mapping[str, str]) -> SmarketsCredentials | None: "sx_bet": load_sx_bet, "betfair": load_betfair, "smarkets": load_smarkets, + "novig": load_novig, + "matchbook": load_matchbook, + "prophetx": load_prophetx, + "azuro": load_azuro, + "myriad": load_myriad, + "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..c4da301 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: @@ -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") @@ -278,6 +301,85 @@ 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 + + +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} + + +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 + + +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 + + +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 +391,12 @@ 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), + ("Matchbook", ("MATCHBOOK_USERNAME",), ask_matchbook), + ("ProphetX", ("PROPHETX_ACCESS_KEY", "PROPHETX_AFFILIATE_KEY"), ask_prophetx), + ("Azuro", ("AZURO_PRIVATE_KEY",), ask_azuro), + ("Myriad", ("MYRIAD_PRIVATE_KEY", "MYRIAD_API_KEY"), ask_myriad), + ("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..2170ae7 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,41 @@ # £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}, + # 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}, + # 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")}, + # 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}, + # 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}, + # 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/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/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/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/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/types.py b/synpath/types.py index 3233e1d..5b946c6 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: @@ -559,14 +563,20 @@ 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, 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). + 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`). + 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 @@ -585,7 +595,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 +606,22 @@ 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. + * `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). + * `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: @@ -611,7 +636,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: @@ -620,11 +645,29 @@ 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 == "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) + 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..a9d7c38 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -50,12 +50,30 @@ 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 if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream + if name in ("NovigMarketStream", "NovigUserStream"): + from . import novig + return getattr(novig, name) + if name == "MatchbookMarketStream": + from .matchbook import MatchbookMarketStream + return MatchbookMarketStream + if name in ("ProphetXMarketStream", "ProphetXUserStream"): + from . import prophetx + return getattr(prophetx, name) + if name == "AzuroMarketStream": + from .azuro import AzuroMarketStream + return AzuroMarketStream + 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 +93,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", + "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", ] 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/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/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/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/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/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/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/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/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/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/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/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_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_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..fd2a149 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -42,7 +42,13 @@ 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 + "n", # Matchbook + "n", # ProphetX + "n", # Azuro + "n", # Myriad + "n"], # Gemini secrets=[KEY, "us-secret"], ) assert run(env, p) == 0 @@ -64,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"], secrets=["nothex", KEY[2:]], ) assert run(tmp_path / ".env", p) == 0 @@ -81,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"], 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 @@ -91,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"]) + 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) @@ -104,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", "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) @@ -113,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"], 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" @@ -122,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"], 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" @@ -135,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"], 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" @@ -144,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"], 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 @@ -156,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"], 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 @@ -165,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"], 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 @@ -178,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"], 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) @@ -190,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"], 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 @@ -201,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"], 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" @@ -212,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"], 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" @@ -223,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"], 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" @@ -234,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"], 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"] @@ -248,3 +254,43 @@ 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"] * 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} + 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"] * 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) + + +def test_myriad_with_only_an_api_key_needs_the_wallet_address(tmp_path: Path): + env = tmp_path / ".env" + 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) + loaded = load_credentials({}, dotenv=env, redact_logs=False)["myriad"] + assert loaded is not None and loaded.private_key is None and loaded.address == WALLET + + +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"] * 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) + 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" 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"])) 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)) 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..13e4c8d --- /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), ""] + ["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 + 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 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")