From db8bd1281c89af6178014ad7b97b60b7119bd006 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 1/8] feat: betfair-trading Co-Authored-By: Claude Opus 5.5 --- .env.example | 9 +- README.md | 4 +- docs/api.md | 3 +- docs/capabilities.md | 34 +- docs/streaming.md | 24 +- docs/trading.md | 96 +- synpath/__init__.py | 4 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 12 +- synpath/server/serve.py | 4 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 2 +- synpath/trading/betfair.py | 1172 +++++++++++++++++++++ synpath/trading/credentials.py | 10 +- synpath/trading/init.py | 5 +- synpath/trading/instruments.py | 11 +- synpath/ws/__init__.py | 8 +- synpath/ws/betfair.py | 492 +++++++-- tests/samples/betfair_trading.json | 465 ++++++++ tests/samples/betfair_trading_stream.json | 463 ++++++++ tests/test_betfair.py | 6 +- tests/test_betfair_trading.py | 541 ++++++++++ tests/test_betfair_ws.py | 154 ++- 24 files changed, 3396 insertions(+), 135 deletions(-) create mode 100644 synpath/trading/betfair.py create mode 100644 tests/samples/betfair_trading.json create mode 100644 tests/samples/betfair_trading_stream.json create mode 100644 tests/test_betfair_trading.py diff --git a/.env.example b/.env.example index 207b78e..9318118 100644 --- a/.env.example +++ b/.env.example @@ -91,10 +91,11 @@ SX_BET_PRIVATE_KEY= SX_BET_ACCOUNT_ADDRESS= SX_BET_TESTNET= -# Betfair Exchange (market data and streams; no order entry yet): every call, -# market data included, needs your own account and application key. The free -# delayed key (1-180 s late, three price levels) is on the developer program -# page or the Accounts API visualiser. Leave the certificate paths empty for an +# Betfair Exchange (market data, streams and order entry): every call, market +# data included, needs your own account and application key. The free delayed +# key (1-180 s late, three price levels) is on the developer program page or +# the Accounts API visualiser; it reads the account but cannot bet. Betting +# needs the live key, which Betfair activates on request. Leave the certificate paths empty for an # interactive login; set both for Betfair's certificate login. The site the # account is on: com (default), it, es, ro or com.au. Betfair's API is refused # from the US and several other countries, and showing its prices to others diff --git a/README.md b/README.md index 40c5d11..beae791 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [Limitless](https://limitless.exchange) | `limitless` | ✓ | ✓ | WebSocket | one book per market, both sides read it | | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Gemini Predictions](https://www.gemini.com/predictions) | `gemini` | ✓ no account, sports taxonomy | ✓ US accounts with Prediction Markets access | WebSocket (no key for books) | one book per contract, in YES space | -| | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | +| | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | ✓ your own account and a live app key | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | | | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | | | [Novig](https://novig.com) | `novig` | ✓ sports, no key | ✓ (US, identity and location checks; Paper for play money) | WebSocket (trading key), else polled from the public book | one book per market, both sides read it | | | [Matchbook](https://www.matchbook.com) | `matchbook` | ✓ no account | ✓ your own login | polled from REST | one book per runner, back and lay read as YES | @@ -89,7 +89,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [Azuro](https://azuro.org) | `azuro` | ✓ sports, no key | ✓ bets with a worst price | WebSocket (no key) | no book: the pool's quoted odds, one level a side sized by its limits | | | [Myriad](https://myriad.markets) | `myriad` | ✓ order-book and AMM markets, sports | ✓ order book | WebSocket (no key) | one book per market, both sides read it; AMM books built from the pool | -Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). +Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key (betting needs Betfair's live key; the free delayed one reads), and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). Gemini Predictions is Gemini Titan's CFTC-registered event-contract exchange. Each contract is a yes/no market named by its instrument symbol, `gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`, and the Gemini event holding the contracts is the event. Sports events carry Gemini's taxonomy (moneyline, spread, total, prop; the sport, scope and statistic), which listings filter on. Market data needs no account; trading needs a Gemini account with Prediction Markets access (US) and an API key. See [capabilities](docs/capabilities.md#gemini). diff --git a/docs/api.md b/docs/api.md index 4de4820..fcc0b25 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,7 +96,8 @@ competition, query)`, the venue's own sports and fixtures as it lists them, and takes `event_type` (an id or a name, `"Soccer"`), `competition`, `event` and `market_type` (`"MATCH_ODDS"`) on `fetch_markets` and `fetch_events`. Every Betfair call needs your own account and application key; see -[Betfair](capabilities.md#betfair). +[Betfair](capabilities.md#betfair). Betting is `BetfairTrading` +([trading](trading.md#betfair)). ```python bf = synpath.Betfair() # BETFAIR_APP_KEY, BETFAIR_USERNAME, BETFAIR_PASSWORD diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..833e5d1 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -113,8 +113,9 @@ session token in memory only, extending it with `keepAlive`. The free side, with no full ladder and no traded volume per runner; every book and market says `info["delayed"]` (Betfair's own `isMarketDataDelayed` when it sends it). Requests are batched to stay inside Betfair's weight limit (200 a -request: best offers weigh 5 a market). There is no order entry for Betfair -yet. +request: best offers weigh 5 a market). Betting (`BetfairTrading`) needs a +live application key, which Betfair activates on request; it refuses bets +from the delayed key. See [trading](trading.md#betfair). **Where it works, and what you may do with it.** Betfair refuses its API from several countries, among them the US, Germany, France, the Netherlands, @@ -527,20 +528,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `betfair` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 200 a request per Betfair market (50 on the Italian exchange), all or none; `market` and `ioc` are bets at the worst price given with the rest cancelled, `fok` its `FILL_OR_KILL`; no `gtd` or post-only | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, sixty a request; `cancel_all_orders` per runner, Betfair market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | yes: a smaller quantity in place (a partial cancel); a new price is the venue's cancel-and-place (`replaceOrders`, a new bet id, the queue place lost) | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; a bet that has left the current orders is found among the cleared ones | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | partial: bets on unsettled markets, by status and placed time | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | partial: one aggregate fill per bet once it has stopped matching, at its average odds; each match is on `BetfairUserStream` | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, matched bets netted per runner; the account's free funds and exposure | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | yes, settled bets from the cleared orders | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's base rate on what the bet wins, makers and takers alike | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | +| `rfq` | yes | no | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the @@ -575,6 +576,7 @@ Every stream answers the `watch_*` keys the same way | `SxBetMarketStream` | yes | partial | yes | yes | no | no | no | no | | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | +| `BetfairUserStream` | no | no | no | no | yes | yes, each increase of a bet's matched stake | yes, the matched ladders per runner | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | | `NovigMarketStream` | yes, per order, with a trading key; partial without one: the public book polled every 5 s | yes; partial when polled | yes; partial when polled | yes; partial when polled | no | no | no | no | | `NovigUserStream` | no | no | no | no | yes | yes | yes | no | diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..242ebf2 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -42,6 +42,7 @@ asyncio.run(main()) | `SxBetMarketStream` | `realtime.sx.bet` (or the testnet) | SX Bet API key (every channel needs one) | whole books, top of book, the tape of taker bets, market changes and settlements | | `SxBetUserStream` | `realtime.sx.bet` | SX Bet API key and the account's address (from the private key, or `SX_BET_ACCOUNT_ADDRESS`) | orders; fills as matched, then escrowed or failed on chain | | `BetfairMarketStream` | `stream-api.betfair.com:443`, a TLS socket (Exchange Stream API), not a WebSocket | Betfair application key and a login (every Betfair call needs one) | best offers per runner to 10 levels, runner and market status; conflated to 3 minutes on a delayed key | +| `BetfairUserStream` | `stream-api.betfair.com:443`, its own connection | the same application key and login | the account's bets on every change; a fill with each increase of a bet's matched stake; matched positions per runner | | `AzuroMarketStream` | `streams.onchainfeed.org` (`dev-streams` for the testnets) | none | each condition's odds as quotes, one-level books sized by the pool's limits, condition status changes | | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | | `NovigMarketStream` | `api.novig.com/v3/ws` (or Paper's); without a key, the public REST book polled | a subaccount's trading key for the WebSocket (the handshake is signed); none to poll | books kept per order, top of book, the tape, market transitions (going in play voids resting orders) | @@ -216,7 +217,23 @@ jittered exponential backoff between attempts. 180,000 ms**: at most one update per market every three minutes, whatever `conflate_ms` asks. The stream logs a warning once when it sees that, and waits a whole conflation window before treating silence as a dead socket. - No public trades, so no `watch_trades`; no account stream yet. + No public trades, so no `watch_trades`. + `BetfairUserStream` is the same protocol on a second connection, one + `orderSubscription` for the whole account (`watch_orders(market_ids)` + drops the rest here). Betfair sends every changed bet whole (`uo`), so + each is an `OrderEvent`; an increase in its matched stake is a + `FillEvent` at the odds the increase averaged, with `:` as its id. A runner removal that reduces a matched price, and a + void, change the bet and the position, never add a fill. The matched + ladders per runner (`mb`, `ml`, `[odds, stake]`) are the + `PositionEvent`s, backs as YES contracts and lays as NO. The first image + after subscribing carries only bets still unmatched in part and is the + baseline fills are counted from; a reconnect resumes from the clock where + Betfair can and is reported with `reconcile_required`, and a message + Betfair marks `503` (its order data running late) is reported as an + error with `reconcile_required` until it clears. Customer strategy refs + narrow it (`strategy_refs=`); it can share `BetfairTrading.session`, as + `synpath serve` does, so one login serves the adapter and both streams. - *Azuro* speaks plain JSON on `wss://streams.onchainfeed.org/v1/streams/feed`: `SubscribeConditions` (and `UnsubscribeConditions`) with the condition ids and the chain's environment (`PolygonUSDT`, `BaseWETH`), and a @@ -486,7 +503,10 @@ documented heartbeat and rely on gRPC keepalive. hand-built messages in its documented shapes (no account was available): a TLS-shaped socket carrying CRLF framing, authentication, an image then deltas and deletes, a resubscribe from the clock, heartbeats, status - changes and the 200-market cap. Not yet run against Betfair. + changes and the 200-market cap. The order stream runs on the order change + messages in Betfair's documentation (a market-level snapshot, a + reconnection image, a runner removal, a VAR void and a delayed + heartbeat). Not yet run against Betfair. - **Smarkets:** polls answered from REST quotes recorded anonymously from api.smarkets.com: a snapshot, unchanged polls, deltas, a whole market, diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..d61ed42 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -72,6 +72,7 @@ private key. `synpath init` tells them apart. | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | | Novig | REST: nothing. Books over the WebSocket: a subaccount's trading key (without one, the public book is polled) | a subaccount's trading key: a key id and its private key file (PEM, Ed25519 or P-256). The management key, optional, opens, funds and labels subaccounts and never trades | the management key on novig.com (paper.novig.com for Paper) -> Profile -> Settings -> Novig API; then `NovigTrading.open_subaccount` with a keypair from `novig_signing.generate_key()` issues the trading key. The private key is never sent | +| Betfair | the application key (the free delayed key) and the account's login, for every call | a live application key (Betfair activates it on request) and the same login: username and password, interactive or by a self-signed client certificate | developer.betfair.com (or the Accounts API visualiser) for the keys; for certificate login, a self-signed certificate uploaded at myaccount.betfair.com/accountdetails/mysecurity?showAPI=1 (Automated Betting Program Access) | | Matchbook | nothing | the account's login: username and password (an account with two-factor login passes a session token made elsewhere) | your matchbook.com account; higher API limits need an API plan from api@matchbook.com | | ProphetX | the Trading API key pair, or a Market Data API key | the Trading API key pair: an access key and a secret key, exchanged for a ten-minute bearer token | prophetx.co -> Menu -> API Integration, on any KYC-approved account (sandbox.prophetx.dev for the sandbox); the secret is shown once. The Market Data API key is issued by ProphetX on request | | Azuro | nothing | the private key of the wallet that signs each bet and holds the bet token, which must allow Azuro's relayer to spend it (`approve()`, once) | exported from the wallet | @@ -103,17 +104,12 @@ The variables each venue reads: | Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | +| Betfair | `BETFAIR_APP_KEY` (a live key bets; the delayed key reads market data and the account), `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` (optional together; Betfair's certificate login), `BETFAIR_JURISDICTION` (optional; `com`, the default, `it`, `es`, `ro` or `com.au`, the site the account is on) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | | ProphetX | `PROPHETX_ACCESS_KEY` and `PROPHETX_SECRET_KEY` (the Trading API: market data, streams, orders), `PROPHETX_AFFILIATE_KEY` (optional; the Market Data API, market data only), `PROPHETX_SANDBOX=1` (optional; ProphetX's sandbox, with demo funds) | | Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance and allowance are read from and the approval is sent through; a public one when unset) | | Gemini | `GEMINI_API_KEY` (`account-...`), `GEMINI_API_SECRET`, `GEMINI_ACCOUNT` (optional; the subaccount a `master-...` key acts for, REST only: the WebSocket refuses master keys), `GEMINI_ENV` = `prod` (the default, real money) or `sandbox` (api.sandbox.gemini.com, with a sandbox key) | -Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, -optional `BETFAIR_CERT_PATH` / `BETFAIR_KEY_PATH` and `BETFAIR_JURISDICTION`) -load the same way and `synpath doctor` reports them, but they serve market data -and the stream only: there is no Betfair order entry yet. See -[Betfair](capabilities.md#betfair). - Smarkets needs no credentials for market data. `SMARKETS_USERNAME` and `SMARKETS_PASSWORD` are optional: they log in for undelayed quotes, and `synpath doctor` reports them. There is no Smarkets order entry yet: betting @@ -613,6 +609,94 @@ free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. +## Betfair + +`BetfairTrading`, built to Betfair's Betting API (`placeOrders`, +`cancelOrders`, `replaceOrders`, `updateOrders`, `listCurrentOrders`, +`listClearedOrders`, `listMarketProfitAndLoss`) and Accounts API +(`getAccountFunds`, `getAccountDetails`) documentation. Every call carries +the application key and the session token of the account's login, made the +way the read adapter makes it: by certificate when `BETFAIR_CERT_PATH` and +`BETFAIR_KEY_PATH` are set, else interactively, extended with `keepAlive` +and kept in memory only; a refused session logs in again once. +`session_token=` reuses a login made elsewhere. **Betting needs a live +application key**: Betfair refuses every bet from the delayed key +(`PermissionDenied`), which still reads orders, positions and the balance. + +An order is a bet to back or lay one runner, +`betfair:-` (with `-` on a handicap +runner). `buy` backs it (takes its YES) at decimal odds `1 / price`; `sell` +lays it (takes its NO) at the same odds. Betfair counts both in the backer's +stake, so `n` contracts stake `n / odds`, rounded down to the penny; a lay's +liability is that stake times `odds - 1`. Prices are snapped onto Betfair's +CLASSIC odds ladder (1.01 to 1000) on the cheaper side: a buy at the next +higher odds, a sell at the next lower, so an order never pays more than +asked. A bet stakes at least the currency's minimum bet size (£1, €1, $1) +unless its payout (stake times odds, its contracts) reaches the minimum bet +payout (£10, €20, $20); the payout rule is not offered on the .it and .es +sites. Orders below both are refused before anything is sent. + +Limits (`gtc`) are Betfair's `LIMIT` with persistence `LAPSE`: the unmatched +part is cancelled when the market turns in play. `params={"persistence": +"PERSIST"}` (or `keep_in_play=True`) keeps it in play, and +`"MARKET_ON_CLOSE"` moves it into the starting-price auction at the off. +`fok` is Betfair's `FILL_OR_KILL`, whose price Betfair reads as the worst +*average* matched price; `params={"min_fill": contracts}` accepts a part of +at least that much. Betfair has no immediate-or-cancel bet, so `market` and +`ioc` are `LAPSE` bets at the price given as the worst accepted, whose +unmatched rest is cancelled as soon as Betfair answers. `gtd`, day and +post-only orders are refused, and so is `reduce_only`, since every bet on a +runner nets. `params={"market_version": n}` lapses the bet if the market has +changed materially since version `n` (a runner removed, turned in play). + +On an in-play market Betfair holds a bet for the market's delay (1 to 10 +seconds) before matching it, and answers only then, so `create_order` +returns the bet already matched or resting. `create_orders` sends one +`placeOrders` per Betfair market, 200 bets a request (50 on the Italian +exchange), which Betfair places all or none, and returns one result each. + +`client_order_id` (up to 32 characters) is sent as the bet's +`customerOrderRef`, which Betfair keeps on every record and on the order +stream; a single order's id also de-duplicates a resubmission within 60 +seconds (`customerRef`), refused as `DuplicateClientOrderId`. A placement +Betfair could not finish in time (`TIMEOUT`) is looked up by that id; without +one it raises `RequestTimeout`. `fetch_order_by_client_id` reads a bet by it. + +`edit_order` with a smaller `quantity` (the order's new total, filled +contracts included) is a partial cancel, which keeps the bet's place in the +queue; a new price is `replaceOrders`, which cancels the unmatched part and +places it again at the new odds as a new bet with a new id +(`queue_priority_preserved` is false, `info["replaced"]` names the old bet). +Betfair cannot raise a stake. `update_persistence(order_id, "PERSIST")` +changes what happens at the off in place. Cancels and edits name the bet's +Betfair market; one this adapter did not place is read first to find it. + +Bets on unsettled markets are `fetch_order`, `fetch_open_orders` and +`fetch_orders` (by status: `open`, `closed`, `canceled`); a bet that has left +them (cancelled, lapsed, settled, voided) is found by `fetch_order` among the +cleared orders. Betfair lists bets, not their matched fragments, so +`fetch_my_trades` is one fill per bet at its average matched odds, for bets +that have stopped matching; each match as it happens is on +`BetfairUserStream` ([Streaming](streaming.md)). Settled bets are +`fetch_settlements`, one per bet. Positions net the matched bets per runner +on the YES leg: backs are YES contracts, lays NO contracts. +`fetch_profit_and_loss(market_ids)` is Betfair's own if-win and if-lose +profit per runner. The balance is what is free to bet +(`availableToBetBalance`), what open bets hold (`exposure`, `locked`), and +the two together, in the account's currency. + +Commission is charged at settlement on net winnings per Betfair market, at +the market's base rate (5% on most .com markets), before the account's +discount; `fetch_fee_estimate` is that rate on what one bet would win, an +upper bound for a bet among others on the same market. Betfair charges +accounts that send very many bets and changes (its Transaction Charges), so +batch where you can. + +Check Betfair's terms for where it may be used before trading: its API is +refused from several countries (the US, Germany, France, the Netherlands, +Singapore, India, Turkey, the UAE and others), and its keys are for an +account holder's own betting. + ## Matchbook `MatchbookTrading`, built to Matchbook's API reference diff --git a/synpath/__init__.py b/synpath/__init__.py index ff88c20..da8aee5 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -128,6 +128,7 @@ def exchange(venue: str, **kwargs) -> Exchange: from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, MatchbookTrading, ProphetXTrading, AzuroTrading, MyriadTrading, GeminiTrading, + BetfairTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -157,7 +158,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "BetfairUserStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") @@ -197,6 +198,7 @@ def __getattr__(name: str): # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "BetfairTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index a19ec85..610baaa 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -388,6 +388,11 @@ def _novig(creds: Credentials) -> TradingExchange: return NovigTrading(creds) # type: ignore[arg-type] +def _betfair(creds: Credentials) -> TradingExchange: + from .trading.betfair import BetfairTrading + return BetfairTrading(creds) # type: ignore[arg-type] + + def _matchbook(creds: Credentials) -> TradingExchange: from .trading.matchbook import MatchbookTrading return MatchbookTrading(creds) # type: ignore[arg-type] @@ -417,6 +422,7 @@ def _gemini(creds: Credentials) -> TradingExchange: "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, "novig": _novig, + "betfair": _betfair, "matchbook": _matchbook, "prophetx": _prophetx, "azuro": _azuro, diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index 352385a..8202549 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -66,6 +66,7 @@ "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), "novig": ("..trading.novig", "NovigTrading"), + "betfair": ("..trading.betfair", "BetfairTrading"), "matchbook": ("..trading.matchbook", "MatchbookTrading"), "prophetx": ("..trading.prophetx", "ProphetXTrading"), "azuro": ("..trading.azuro", "AzuroTrading"), diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index a4672bf..e22d074 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -168,10 +168,14 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] private=MyriadUserStream(address=address, token=token) if address and token else None, ) elif venue == "betfair" and creds is not None: - # Market data only: no Betfair order entry, so no account stream. - # Every Betfair stream, books included, logs in with the account. - from ..ws.betfair import BetfairMarketStream - out[venue] = VenueStreams(market=BetfairMarketStream(credentials=creds), private=None) + # Every Betfair stream, books included, logs in with the account; + # both share the trading adapter's session, so one login serves all. + from ..ws.betfair import BetfairMarketStream, BetfairUserStream + session = getattr(adapter, "session", None) + out[venue] = VenueStreams( + market=BetfairMarketStream(credentials=creds, session=session), + private=BetfairUserStream(credentials=creds, session=session), + ) elif venue == "gemini": # Books are public; the account stream signs its handshake with # the API key, which must be account-scoped and time-based. diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 462b90e..e1900af 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -35,8 +35,8 @@ log = logging.getLogger("synpath.serve") -VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "novig", - "matchbook", "prophetx", "azuro", "myriad", "gemini") +VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", + "novig", "matchbook", "prophetx", "azuro", "myriad", "gemini") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index a7a2365..e3dac87 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -84,6 +84,9 @@ def __getattr__(name: str): if name == "NovigTrading": from .novig import NovigTrading return NovigTrading + if name == "BetfairTrading": + from .betfair import BetfairTrading + return BetfairTrading if name == "MatchbookTrading": from .matchbook import MatchbookTrading return MatchbookTrading @@ -105,7 +108,7 @@ def __getattr__(name: str): __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", - "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", "BetfairTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index 1746aae..41e2fb4 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -130,7 +130,7 @@ def doctor(dotenv: str | None) -> int: # The application key is a secret too; the username names the account. stage = creds.jurisdiction login = "certificate" if creds.cert_path else "interactive" - identity = f"user {creds.username}, app key loaded, {login} login" + identity = f"user {creds.username}, app key loaded, {login} login (betting needs a live key)" elif venue == "prophetx": stage = "sandbox" if creds.sandbox else "production" affiliate = "; affiliate key loaded: market data from the Market Data API" if creds.affiliate_key else "" diff --git a/synpath/trading/betfair.py b/synpath/trading/betfair.py new file mode 100644 index 0000000..306e674 --- /dev/null +++ b/synpath/trading/betfair.py @@ -0,0 +1,1172 @@ +"""Betfair order entry: bets placed, replaced, reduced and cancelled, and the +account's orders, matched bets, settled bets and funds on the YES leg. + +Built to Betfair's Betting API (`placeOrders`, `cancelOrders`, +`replaceOrders`, `updateOrders`, `listCurrentOrders`, `listClearedOrders`, +`listMarketProfitAndLoss`) and Accounts API (`getAccountFunds`, +`getAccountDetails`) documentation; its records follow the documentation's +examples. Every call carries the application key and the session of the +account's login, interactive or by certificate, exactly as the read adapter +makes it; see `synpath.betfair.BetfairSession`. **Betting needs a live +application key**: Betfair refuses every bet from a delayed key +(`PERMISSION_DENIED`), which still reads the account. + +**An order is a bet to back or lay one runner.** `buy` backs the runner +(takes its YES) at decimal odds 1/price; `sell` lays it (takes its NO) at the +same odds. Betfair counts both in the backer's stake (a lay's `size` is the +backer's stake it accepts): an order for `n` contracts stakes `n / odds`, +rounded down to the penny, and a lay's liability is that stake times +`odds - 1`. Prices are snapped onto Betfair's CLASSIC odds ladder (1.01 to +1000) on the cheaper side: a buy at higher odds (a lower YES price), a sell +at lower odds (a higher YES price). + +**Minimum stakes.** A bet stakes at least the currency's minimum bet size +(£1, €1, $1) unless what it pays out, stake times odds (its contracts), is at +least the currency's minimum bet payout (£10, €20, $20): 10p at 100 is a bet. +The payout rule is not offered on the Italian and Spanish sites. Orders below +both are refused before anything is sent. + +**Persistence and time in force.** A limit (`gtc`) is Betfair's `LIMIT` with +persistence `LAPSE` by default: its unmatched part is cancelled when the +market turns in play. `params={"persistence": "PERSIST"}` (or +`keep_in_play=True`) keeps it in play; `"MARKET_ON_CLOSE"` moves it into the +starting-price auction at the off. `fok` is Betfair's `FILL_OR_KILL`, matched +whole or not at all (`params={"min_fill": contracts}` accepts a part of at +least that much), and Betfair reads its price as the worst *average* price. +Betfair has no immediate-or-cancel bet, so a market order, or an `ioc` +limit, is a `LAPSE` bet at the price given as the worst accepted whose +unmatched rest is cancelled as soon as Betfair answers. GTD, day and +post-only orders are not offered. + +**In-play delay.** On an in-play market Betfair holds a bet for the market's +`betDelay` (1 to 10 seconds) before matching it, and `placeOrders` answers +only after that, so an order returns already matched or resting. A request +Betfair could not finish in time answers `TIMEOUT`; the order is then looked +up by its client order id, which Betfair keeps. + +**Client order ids.** `client_order_id` is sent as the bet's +`customerOrderRef` (up to 32 characters), which every Betfair record and the +order stream carry back, and a single order's id also de-duplicates a +resubmission within 60 seconds (`customerRef`). + +**Edits.** A new price is `replaceOrders`: Betfair cancels the unmatched part +and places it again at the new odds as a new bet with a new id, losing its +place in the queue. A smaller quantity is a partial cancel, which keeps the +place. Betfair cannot raise a bet's stake. `update_persistence` changes what +happens at the off in place (`updateOrders`). + +**Positions** net per runner on the YES leg from the account's matched bets +on unsettled markets: backs are YES contracts, lays NO contracts, a matched +stake S at average odds O being S·O contracts either way. + +**Fees** are commission on the account's net winnings per Betfair market at +settlement, at the market's base rate (5% on most .com markets). +""" +from __future__ import annotations + +import asyncio +import bisect +import re +import time +from decimal import ROUND_DOWN, Decimal +from typing import Any + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..betfair import ( + AUTH_ERRORS, CLASSIC_LADDER, JURISDICTIONS, RATE_ERRORS, SESSION_ERRORS, BetfairSession, api_error_code, + iso_ms, parse_runner_id, runner_native_id, +) +from ..errors import AuthenticationError, BadRequest, ExchangeError, RateLimitExceeded, RequestTimeout +from ..types import Page, iso +from .base import TradingExchange +from .credentials import BetfairCredentials +from .errors import ( + DuplicateClientOrderId, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, + PermissionDenied, +) +from .limiter import BudgetLimiter, Priority +from .types import ( + Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, + Position, PositionSide, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "betfair" +ONE = Decimal("1") +ZERO = Decimal("0") +PRICE_PLACES = Decimal("0.0000000001") +PENNY = Decimal("0.01") + +BETTING_PATH = "/exchange/betting/rest/v1.0" +ACCOUNT_PATH = "/exchange/account/rest/v1.0" + +PLACE_PER_REQUEST = {"it": 50} +"""Place instructions one `placeOrders` request carries: 200 on the global +exchange, 50 on the Italian one.""" +DEFAULT_PLACE_PER_REQUEST = 200 +CANCEL_PER_REQUEST = 60 +"""Cancel, replace and update instructions per request.""" + +PAGE_SIZE = 1000 +"""Records one `listCurrentOrders` or `listClearedOrders` request returns at most.""" + +MAX_PAGES = 50 +"""Most pages one listing call reads.""" + +MAX_IDS = 250 +"""Bet ids and market ids together one `listCurrentOrders` request may name.""" + +REF_LIMIT = 32 +"""Longest `customerOrderRef` and `customerRef` Betfair takes.""" +CUSTOMER_REF = re.compile(r"^[A-Za-z0-9:\-._+*;~]{1,32}$") +"""What a `customerRef` (the request's de-duplication key) may contain.""" + +PERSISTENCE = ("LAPSE", "PERSIST", "MARKET_ON_CLOSE") + +MINIMUMS: dict[str, tuple[Decimal, Decimal]] = { + "GBP": (Decimal("1"), Decimal("10")), "EUR": (Decimal("1"), Decimal("20")), + "USD": (Decimal("1"), Decimal("20")), "AUD": (Decimal("1"), Decimal("30")), + "CAD": (Decimal("2"), Decimal("30")), "HKD": (Decimal("10"), Decimal("125")), + "DKK": (Decimal("15"), Decimal("150")), "NOK": (Decimal("10"), Decimal("150")), + "SEK": (Decimal("15"), Decimal("150")), "SGD": (Decimal("2"), Decimal("30")), + "NZD": (Decimal("2"), Decimal("10")), "RON": (Decimal("5"), Decimal("50")), + "BRL": (Decimal("5"), Decimal("50")), "MXN": (Decimal("30"), Decimal("300")), +} +"""(Min Bet Size, Min Bet Payout) by account currency, from Betfair's +Currency Parameters. A bet is valid at or above the first, or below it when +its payout reaches the second.""" +NO_PAYOUT_RULE = frozenset({"it", "es"}) +"""Sites where the minimum-payout rule is not offered.""" + +CLEARED_STATUSES = ("CANCELLED", "LAPSED", "SETTLED", "VOIDED") +"""The `betStatus` values `fetch_order` searches, in order, for a bet that has +left the current orders.""" + +SIDES = {"BACK": Side.BUY, "LAY": Side.SELL, "B": Side.BUY, "L": Side.SELL} + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return ZERO + return Decimal(str(value)) + + +def yes_price(odds: Any) -> Decimal: + """Decimal odds as the YES price 1/O, to ten places.""" + return (ONE / D(odds)).quantize(PRICE_PLACES) + + +def side_of(native: Any) -> Side: + """`BACK` takes YES; `LAY` takes NO.""" + return SIDES.get(str(native or "").upper(), Side.SELL) + + +def odds_for(side: Side, price: Any) -> Decimal: + """The ladder odds for an order at this YES price, on the cheaper side: a + buy at the first odds at or above 1/price, a sell at the last at or below.""" + p = D(price) + if not 0 < p < 1: + raise InvalidOrder(f"{VENUE}: a price is a YES probability strictly between 0 and 1, not {price}") + # To a millionth of a point of odds, so a price such as 0.3333333333 + # meant as odds 3 is not pushed to the next tick by its last digit. + raw = (ONE / p).quantize(Decimal("0.000001")) + if side == Side.BUY: + index = bisect.bisect_left(CLASSIC_LADDER, raw) + if index >= len(CLASSIC_LADDER): + raise InvalidOrder(f"{VENUE}: {price} is below the ladder's lowest YES price (odds 1000)") + return CLASSIC_LADDER[index] + index = bisect.bisect_right(CLASSIC_LADDER, raw) - 1 + if index < 0: + raise InvalidOrder(f"{VENUE}: {price} is above the ladder's highest YES price (odds 1.01)") + return CLASSIC_LADDER[index] + + +def stake_for(quantity: Any, odds: Decimal) -> Decimal: + """The backer's stake for `quantity` contracts at `odds`, down to the penny.""" + stake = (D(quantity) / odds).quantize(PENNY, rounding=ROUND_DOWN) + if stake <= 0: + raise InvalidOrder(f"{VENUE}: {quantity} contracts at odds {odds} is less than a penny of stake") + return stake + + +def check_minimum(stake: Decimal, odds: Decimal, *, currency: str | None, jurisdiction: str = "com") -> None: + """Refuse a bet below both of the currency's minimums. A currency this + module has no figures for is left for Betfair to judge.""" + minimums = MINIMUMS.get((currency or "").upper()) + if minimums is None: + return + size, payout = minimums + if stake >= size: + return + if jurisdiction not in NO_PAYOUT_RULE and stake * odds >= payout: + return + rule = f"{size} {currency}" + ("" if jurisdiction in NO_PAYOUT_RULE else f", or a payout of {payout} {currency}") + raise InvalidOrder(f"{VENUE}: a stake of {stake} at odds {odds} is below Betfair's minimum bet ({rule})") + + +def _number(value: Decimal) -> float | int: + """A Decimal as the JSON number Betfair reads.""" + return int(value) if value == value.to_integral_value() else float(value) + + +def _runner_id(market_id: Any, selection_id: Any, handicap: Any) -> str: + return ids.qualify(VENUE, runner_native_id(str(market_id), selection_id, handicap or 0)) + + +def _parse_runner(market_id: str) -> tuple[str, int, float]: + native = ids.native(VENUE, market_id) + return parse_runner_id(native) + + +def _market_of(market_id: str) -> str: + """The Betfair market id of a runner id or of a Betfair market (event) id.""" + native = ids.native(VENUE, market_id) + return parse_runner_id(native)[0] if "-" in native else native + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def _price_size(row: dict[str, Any]) -> tuple[Decimal, Decimal]: + """(odds, stake) of a current order or of a place instruction.""" + price_size = row.get("priceSize") or row.get("limitOrder") or (row.get("instruction") or {}).get("limitOrder") or {} + return D(price_size.get("price")), D(price_size.get("size")) + + +def status_of(row: dict[str, Any], *, size: Decimal) -> OrderStatus: + """An order's state from Betfair's status and the amounts on it. A bet no + longer executable ended matched in full (`closed`), killed by its fill or + kill (`expired`), or cancelled or lapsed for the rest (`canceled`).""" + native = str(row.get("status") or row.get("orderStatus") or "").upper() + matched = D(row.get("sizeMatched")) + if native == "PENDING": + return OrderStatus.PENDING + if native == "EXECUTABLE": + return OrderStatus.OPEN + if native == "EXPIRED": + return OrderStatus.EXPIRED + if native == "EXECUTION_COMPLETE": + ended = D(row.get("sizeCancelled")) + D(row.get("sizeLapsed")) + D(row.get("sizeVoided")) + if ended > 0 or (size > 0 and matched < size): + return OrderStatus.CANCELED + return OrderStatus.CLOSED + return OrderStatus.OPEN + + +TIME_IN_FORCE = {"FILL_OR_KILL": TimeInForce.FOK} + + +def order_of_current(row: dict[str, Any], *, account: Account | None = None, currency: str | None = None) -> Order: + """One current order (`listCurrentOrders`, or a stream order in the same + shape). Stakes become contracts at the bet's own odds; what matched is + averaged at its matched odds.""" + odds, size = _price_size(row) + matched, remaining = D(row.get("sizeMatched")), D(row.get("sizeRemaining")) + status = status_of(row, size=size) + average = D(row.get("averagePriceMatched")) + return Order( + id=str(row.get("betId") or ""), + client_order_id=row.get("customerOrderRef") or None, + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + side=side_of(row.get("side")), + type=OrderType.LIMIT, + time_in_force=TIME_IN_FORCE.get(str(row.get("timeInForce") or ""), TimeInForce.GTC), + status=status, + price=yes_price(odds) if odds else None, + quantity=size * odds, + filled=matched * odds, + remaining=remaining * odds if status in (OrderStatus.OPEN, OrderStatus.PENDING) else ZERO, + average_price=yes_price(average) if matched and average else None, + cost=matched if side_of(row.get("side")) == Side.BUY else matched * (average - 1) if average else None, + fee_currency=currency, + created_at=iso_ms(row.get("placedDate")), + updated_at=iso_ms(row.get("matchedDate")), + info=row, + ) + + +def order_of_report( + report: dict[str, Any], *, market_id: str, request: OrderRequest | None = None, account: Account | None = None, + currency: str | None = None, +) -> Order: + """The order from a successful place instruction report. Betfair answers + with the bet id and what matched on arrival; the order's state follows + from those when the report carries none.""" + instruction = report.get("instruction") or {} + limit = instruction.get("limitOrder") or {} + odds, size = D(limit.get("price")), D(limit.get("size")) + matched = D(report.get("sizeMatched")) + native = report.get("orderStatus") + fok = limit.get("timeInForce") == "FILL_OR_KILL" + if native is None: + if matched >= size > 0: + native = "EXECUTION_COMPLETE" + elif fok: + native = "EXPIRED" + else: + native = "EXECUTABLE" + elif fok and native == "EXECUTION_COMPLETE" and matched < size: + native = "EXPIRED" + row = { + "betId": report.get("betId"), "marketId": market_id, "selectionId": instruction.get("selectionId"), + "handicap": instruction.get("handicap"), "priceSize": {"price": limit.get("price"), "size": limit.get("size")}, + "side": instruction.get("side"), "status": native, "persistenceType": limit.get("persistenceType"), + "orderType": instruction.get("orderType"), "placedDate": report.get("placedDate"), + "averagePriceMatched": report.get("averagePriceMatched"), "sizeMatched": report.get("sizeMatched"), + "sizeRemaining": _number(size - matched) if native == "EXECUTABLE" else 0, + "customerOrderRef": instruction.get("customerOrderRef"), "timeInForce": limit.get("timeInForce"), + } + order = order_of_current(row, account=account, currency=currency) + update: dict[str, Any] = {"info": {**report, "marketId": market_id}} + if request is not None: + update.update({ + "client_order_id": request.client_order_id, "type": request.type, "time_in_force": request.time_in_force, + "book": request.book, "trader": request.trader, "tags": request.tags, + }) + if odds and size and matched >= size: + update["remaining"] = ZERO + return order.model_copy(update=update) + + +def order_of_cleared(row: dict[str, Any], *, account: Account | None = None, currency: str | None = None) -> Order: + """A bet that has left the current orders, from `listClearedOrders` at bet + level: cancelled or lapsed (nothing matched), voided, or settled.""" + bet_status = str(row.get("betStatus") or "").upper() + odds = D(row.get("priceRequested")) + settled = D(row.get("sizeSettled")) + left = D(row.get("sizeCancelled")) + matched_odds = D(row.get("priceMatched")) or odds + size = settled + left + status = OrderStatus.CLOSED if bet_status == "SETTLED" and not left else OrderStatus.CANCELED + return Order( + id=str(row.get("betId") or ""), + client_order_id=row.get("customerOrderRef") or None, + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + side=side_of(row.get("side")), + type=OrderType.LIMIT, + time_in_force=TimeInForce.GTC, + status=status, + price=yes_price(odds) if odds else None, + quantity=size * odds, + filled=settled * odds if bet_status == "SETTLED" else ZERO, + remaining=ZERO, + average_price=yes_price(matched_odds) if settled and matched_odds else None, + fee_currency=currency, + created_at=iso_ms(row.get("placedDate")), + updated_at=iso_ms(row.get("settledDate")), + info=row, + ) + + +def fill_of_current(row: dict[str, Any], *, account: Account | None = None, currency: str | None = None) -> Fill | None: + """What has matched on one bet, as one fill at its average matched odds. + Betfair lists bets, not their matched fragments, so the fill carries the + bet's id and its matched stake times those odds in contracts.""" + matched, average = D(row.get("sizeMatched")), D(row.get("averagePriceMatched")) + if not matched or not average: + return None + return Fill( + id=str(row.get("betId") or ""), + order_id=str(row.get("betId") or ""), + client_order_id=row.get("customerOrderRef") or None, + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + side=side_of(row.get("side")), + price=yes_price(average), + quantity=matched * average, + fee=None, + fee_currency=currency, + liquidity=Liquidity.UNKNOWN, + timestamp=iso_ms(row.get("matchedDate")) or iso_ms(row.get("placedDate")) or 0, + info={**row, "aggregated": True}, + ) + + +def positions_of_orders(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: + """Current orders' matched stakes netted per runner on the YES leg: backs + are YES contracts, lays NO contracts, at each bet's average matched odds.""" + held: dict[str, dict[str, Any]] = {} + for row in rows: + matched, average = D(row.get("sizeMatched")), D(row.get("averagePriceMatched")) + if not matched or not average: + continue + synpath_id = _runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")) + entry = held.setdefault(synpath_id, {"yes": ZERO, "no": ZERO, "stake": ZERO, "margin": ZERO, "bets": []}) + back = side_of(row.get("side")) == Side.BUY + entry["yes" if back else "no"] += matched * average + entry["stake"] += matched + entry["margin"] += matched if back else matched * (average - 1) + entry["bets"].append(row.get("betId")) + out = [] + for synpath_id, entry in held.items(): + net = entry["yes"] - entry["no"] + traded = entry["yes"] + entry["no"] + out.append(Position( + venue=VENUE, + account=account, + market_id=synpath_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), + inventory_yes=entry["yes"], + inventory_no=entry["no"], + # The average YES price of every matched bet: stakes over contracts. + entry_price=(entry["stake"] / traded).quantize(PRICE_PLACES) if traded else None, + margin=entry["margin"], + info={"bets": entry["bets"]}, + )) + return out + + +RESULTS = {("BACK", "WON"): "yes", ("BACK", "LOST"): "no", ("LAY", "WON"): "no", ("LAY", "LOST"): "yes", + ("BACK", "PLACE"): "yes", ("LAY", "PLACE"): "yes"} +"""A settled bet's outcome, which is the bet's own (a lay that won means the +runner lost), as the runner's YES result.""" + + +def settlement_of(row: dict[str, Any], *, account: Account | None = None) -> Settlement: + """One settled bet (`listClearedOrders`, bet level).""" + side = str(row.get("side") or "").upper() + odds = D(row.get("priceMatched")) or D(row.get("priceRequested")) + stake = D(row.get("sizeSettled")) + profit = row.get("profit") + return Settlement( + venue=VENUE, + account=account, + market_id=_runner_id(row.get("marketId"), row.get("selectionId"), row.get("handicap")), + held=PositionSide.LONG if side == "BACK" else PositionSide.SHORT, + result=RESULTS.get((side, str(row.get("betOutcome") or "").upper())), + won=(D(profit) > 0) if profit is not None else None, + quantity=stake * odds, + cost=stake if side == "BACK" else None, + pnl=D(profit) if profit is not None else None, + timestamp=iso_ms(row.get("settledDate")), + info=row, + ) + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +INVALID = frozenset({ + "INVALID_BET_SIZE", "INVALID_RUNNER", "INVALID_ODDS", "INVALID_PERSISTENCE_TYPE", "INVALID_BACK_LAY_COMBINATION", + "INVALID_BID_TYPE", "INVALID_PRICE_EDIT", "INVALID_MIN_FILL_SIZE", "INVALID_CUSTOMER_ORDER_REF", + "INVALID_ORDER", "INVALID_MARKET_ID", "INVALID_INPUT_DATA", "TOO_MANY_INSTRUCTIONS", "INVALID_MARKET_VERSION", + "INVALID_PROFIT_RATIO", "INVALID_ORDER_TYPE", "INVALID_PERSISTENCE", +}) +HALTED = frozenset({ + "MARKET_SUSPENDED", "MARKET_NOT_OPEN_FOR_BETTING", "RUNNER_REMOVED", "MARKET_NOT_OPEN_FOR_BSP_BETTING", +}) + + +def error_of(code: str | None, *, what: str, body: Any = None) -> ExchangeError: + """Betfair's refusal of an instruction or a whole request as a typed + error, by its error code.""" + code = str(code or "") + text = f"{VENUE}: {what} refused: {code or 'no reason given'}" + if code == "INSUFFICIENT_FUNDS": + return InsufficientFunds(text, body=body) + if code in HALTED: + return MarketHalted(text, body=body) + if code == "PERMISSION_DENIED": + return PermissionDenied( + f"{text}: Betfair takes bets only on a live application key, and not from restricted countries", + body=body, + ) + if code == "DUPLICATE_TRANSACTION": + return DuplicateClientOrderId(f"{text}: this client order id was sent in the last 60 seconds") + if code == "INVALID_BET_ID": + return OrderNotFound(text, body=body) + if code in INVALID: + return InvalidOrder(text) + return OrderRejected(text, reason=code.lower() or None, info=body if isinstance(body, dict) else {}, body=body) + + +def _failure(report: dict[str, Any], execution: dict[str, Any], *, what: str) -> ExchangeError: + """The error for one failed instruction: its own code, else the request's.""" + code = report.get("errorCode") + if not code or code in ("ERROR_IN_ORDER", "RELATED_ACTION_FAILED"): + code = execution.get("errorCode") if execution.get("errorCode") != "BET_ACTION_ERROR" else code + return error_of(code, what=what, body=report) + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def immediate(request: OrderRequest) -> bool: + """Whether the order takes what is there and leaves nothing resting + (other than a fill or kill, which Betfair holds to that itself).""" + return request.type == OrderType.MARKET or request.time_in_force == TimeInForce.IOC + + +def persistence_of(params: dict[str, Any]) -> str: + """The `persistenceType` a request asks for: `LAPSE` unless it says + otherwise (`persistence=`, or `keep_in_play=True` for `PERSIST`).""" + wanted = params.get("persistence") + if wanted is None: + return "PERSIST" if params.get("keep_in_play") else "LAPSE" + wanted = str(wanted).upper() + if wanted not in PERSISTENCE: + raise InvalidOrder(f"{VENUE}: persistence is LAPSE, PERSIST or MARKET_ON_CLOSE, not {params['persistence']!r}") + return wanted + + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.time_in_force in (TimeInForce.GTD, TimeInForce.DAY): + raise InvalidOrder(f"{VENUE}: Betfair bets rest until matched, cancelled or lapsed; gtc, ioc or fok only") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") + if request.post_only: + raise InvalidOrder(f"{VENUE}: Betfair has no post-only bet") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: Betfair nets every bet on a runner; there is no reduce-only bet") + if request.client_order_id is not None and len(request.client_order_id) > REF_LIMIT: + raise InvalidOrder(f"{VENUE}: a client order id is at most {REF_LIMIT} characters (Betfair's customerOrderRef)") + + +def build_instruction(request: OrderRequest) -> dict[str, Any]: + """One request as a Betfair place instruction, before anything is sent.""" + check_request(request) + _, selection, handicap = _parse_runner(request.market_id) + odds = odds_for(request.side, request.price) + stake = stake_for(request.quantity, odds) + limit: dict[str, Any] = {"size": _number(stake), "price": _number(odds)} + if request.time_in_force == TimeInForce.FOK: + limit["timeInForce"] = "FILL_OR_KILL" + if request.params.get("min_fill") is not None: + limit["minFillSize"] = _number(stake_for(request.params["min_fill"], odds)) + else: + persistence = persistence_of(request.params) + if immediate(request) and persistence != "LAPSE": + raise InvalidOrder(f"{VENUE}: a market or ioc order leaves nothing resting, so it cannot persist") + limit["persistenceType"] = persistence + instruction: dict[str, Any] = { + "selectionId": selection, "handicap": handicap, "side": "BACK" if request.side == Side.BUY else "LAY", + "orderType": "LIMIT", "limitOrder": limit, + } + if request.client_order_id: + instruction["customerOrderRef"] = request.client_order_id + return instruction + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class BetfairTrading(TradingExchange): + """Betfair Exchange order entry, on the account's live application key. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.betfair import BetfairTrading + + async with BetfairTrading(require("betfair", load_credentials())) as bf: + print(await bf.fetch_balance()) + ``` + """ + + id = VENUE + name = "Betfair Exchange" + has: dict[str, Capability] = { + "create_order": True, + # One placeOrders per Betfair market, 200 bets a request; atomic per market. + "create_orders": True, + "cancel_order": True, + "cancel_orders": True, + "cancel_all_orders": True, + # A new price is a new bet (replaceOrders); a smaller quantity a partial cancel. + "edit_order": True, + "fetch_order": True, + "fetch_open_orders": True, + # Bets on markets not yet settled only. + "fetch_orders": "partial", + # One aggregate fill per bet once it has stopped matching, on unsettled markets. + "fetch_my_trades": "partial", + "fetch_positions": True, + "fetch_balance": True, + "fetch_settlements": True, + "fetch_queue_position": False, + "fetch_fee_estimate": True, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: BetfairCredentials | None = None, + *, + session: BetfairSession | None = None, + session_token: str | None = None, + account_name: str = "default", + catalog: Any = None, + limiter: BudgetLimiter | None = None, + timeout: float = 60.0, + client: Any = None, + identity_client: Any = None, + ): + """`credentials` log in on the first call (by certificate when the + certificate and key are set); `session_token` reuses a login made + elsewhere. `timeout` allows for the in-play delay a placement waits.""" + import httpx + + if session is None: + if credentials is None: + from .errors import CredentialsMissing + + raise CredentialsMissing( + f"{VENUE}: set BETFAIR_APP_KEY, BETFAIR_USERNAME and BETFAIR_PASSWORD (a live application key bets)" + ) + session = BetfairSession( + app_key=credentials.app_key, username=credentials.username, password=credentials.password, + cert_path=credentials.cert_path, key_path=credentials.key_path, + jurisdiction=credentials.jurisdiction, session_token=session_token, client=identity_client, + timeout=timeout, + ) + self.credentials = credentials + self.session = session + self.jurisdiction = session.jurisdiction + self.account = Account(venue=VENUE, name=account_name) + # Betfair publishes no request rate for betting, only a charge on + # accounts that send very many transactions; kept modest. + self.limiter = limiter or BudgetLimiter(read_per_second=5, write_per_second=20) + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + shared.headers["Accept"] = "application/json" + self.host = f"https://{JURISDICTIONS[self.jurisdiction]['api']}" + self.http = AsyncHttpClient(self.host, limiter=None, client=shared, venue=VENUE, attempts=2) + self._own_catalog = catalog is None + if catalog is None: + from ..betfair import Betfair + + catalog = Betfair(session=session) + self.catalog = catalog + """The read adapter, on the same session: fee schedules.""" + self._currency: str | None = None + self._markets: dict[str, str] = {} + """Bet id -> Betfair market id, for the cancels and edits Betfair wants a market for.""" + + # -- plumbing ------------------------------------------------------------- + + @property + def place_per_request(self) -> int: + return PLACE_PER_REQUEST.get(self.jurisdiction, DEFAULT_PLACE_PER_REQUEST) + + async def _call( + self, operation: str, body: dict[str, Any], *, api: str = BETTING_PATH, kind: str = "read", + priority: Priority = Priority.NORMAL, + ) -> Any: + """One operation with the session's headers, paced; a refused session + logs in again once. Betfair's own error code names the error.""" + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + for attempt in range(2): + token = await asyncio.to_thread(self.session.token) + headers = {"X-Application": self.session.app_key, "X-Authentication": token, + "Content-Type": "application/json"} + try: + return await self.http.post(f"{api}/{operation}/", json=body, headers=headers) + except ExchangeError as exc: + code = api_error_code(exc.body) + if code in SESSION_ERRORS and attempt == 0: + self.session.invalidate() + continue + if code is None: + raise + if code in AUTH_ERRORS: + raise AuthenticationError(f"{VENUE}: {operation} refused: {code}", body=exc.body, + status=exc.status) from None + if code in RATE_ERRORS: + raise RateLimitExceeded(f"{VENUE}: {operation} refused: {code}") from None + if code in ("PERMISSION_DENIED", "INSUFFICIENT_FUNDS", "DUPLICATE_TRANSACTION") or code in HALTED: + raise error_of(code, what=operation, body=exc.body) from None + raise BadRequest(f"{VENUE}: {operation} refused: {code}", body=exc.body, status=exc.status) from None + raise AssertionError("unreachable") # pragma: no cover + + async def currency(self) -> str: + """The account's currency, read once: every stake and balance is in it.""" + if self._currency is None: + details = await self._call("getAccountDetails", {}, api=ACCOUNT_PATH) or {} + self._currency = str(details.get("currencyCode") or "") + return self._currency + + async def _market_for(self, order_id: str, market_id: str | None) -> str: + """The Betfair market a bet is on: from the caller, from this adapter's + own placements, or read back from the account's orders.""" + if market_id: + return _market_of(market_id) + known = self._markets.get(str(order_id)) + if known: + return known + order = await self.fetch_order(order_id) + return _market_of(order.market_id) + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest) -> Order: + """Place one bet. Betfair answers once it has been matched or put on the + book (after the in-play delay on an in-play market); an immediate + order's unmatched rest is cancelled before this returns.""" + result = await self.create_orders([request]) + if isinstance(result[0], Exception): + raise result[0] + return result[0] + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many bets, one `placeOrders` per Betfair market (200 a request, 50 + on the Italian exchange), one result each in the order asked for. A + refused bet comes back as its exception. Betfair places a request's + bets all or none, so one refused bet fails the others on its market.""" + out: list[Order | Exception | None] = [None] * len(requests) + groups: dict[str, list[tuple[int, dict[str, Any]]]] = {} + currency: str | None = None + for i, request in enumerate(requests): + try: + instruction = build_instruction(request) + limit = instruction["limitOrder"] + if currency is None: + currency = await self.currency() + check_minimum(D(limit["size"]), D(limit["price"]), currency=currency, jurisdiction=self.jurisdiction) + groups.setdefault(_market_of(request.market_id), []).append((i, instruction)) + except (InvalidOrder, BadRequest) as exc: + out[i] = exc + for market, entries in groups.items(): + for start in range(0, len(entries), self.place_per_request): + chunk = entries[start:start + self.place_per_request] + for i, result in zip([i for i, _ in chunk], await self._place(market, chunk, requests)): + out[i] = result + return await self._finish_immediate(requests, out) # type: ignore[arg-type] + + async def _place( + self, market: str, chunk: list[tuple[int, dict[str, Any]]], requests: list[OrderRequest], + ) -> list[Order | Exception]: + body: dict[str, Any] = {"marketId": market, "instructions": [instruction for _, instruction in chunk]} + first = requests[chunk[0][0]] + if len(chunk) == 1 and first.client_order_id and CUSTOMER_REF.match(first.client_order_id): + # One order's id also keys Betfair's 60-second de-duplication. + body["customerRef"] = first.client_order_id + version = first.params.get("market_version") + if version is not None: + # Lapse the bets if the market has changed materially since then + # (a runner removed, turned in play). + body["marketVersion"] = {"version": int(version)} + try: + execution = await self._call("placeOrders", body, kind="write") or {} + except ExchangeError as exc: + return [exc] * len(chunk) + reports = list(execution.get("instructionReports") or []) + currency = self._currency + out: list[Order | Exception] = [] + for n, (i, instruction) in enumerate(chunk): + report = reports[n] if n < len(reports) and isinstance(reports[n], dict) else {} + request = requests[i] + status = report.get("status") or execution.get("status") + if status == "TIMEOUT": + out.append(await self._after_timeout(request)) + elif status == "SUCCESS" and report.get("betId"): + order = order_of_report(report, market_id=market, request=request, + account=request.account or self.account, currency=currency) + self._markets[order.id] = market + out.append(order) + else: + out.append(_failure(report, execution, what="placeOrders")) + return out + + async def _after_timeout(self, request: OrderRequest) -> Order | Exception: + """Betfair did not finish the bet in time: it may yet be placed, or + lapse within fifteen seconds. Look for it by its client order id.""" + if request.client_order_id: + found = await self._current({"customerOrderRefs": [request.client_order_id], "orderProjection": "ALL"}) + if found: + return found[0] + return RequestTimeout( + f"{VENUE}: Betfair timed out placing the bet; its state is unknown for up to 15 seconds -- read the " + f"open orders" + (f" for client order id {request.client_order_id}" if request.client_order_id else "") + ) + + async def _finish_immediate( + self, requests: list[OrderRequest], placed: list[Order | Exception], + ) -> list[Order | Exception]: + """Cancel the unmatched rest of every market or IOC order, so nothing + of them is left resting. Betfair answered after any in-play delay, + so the rest is already on the book.""" + to_cancel = [i for i, (request, order) in enumerate(zip(requests, placed)) + if immediate(request) and isinstance(order, Order) and order.status == OrderStatus.OPEN] + if not to_cancel: + return placed + out = list(placed) + cancelled = await self.cancel_orders([placed[i].id for i in to_cancel]) # type: ignore[union-attr] + for i, result in zip(to_cancel, cancelled): + if isinstance(result, Order): + request = requests[i] + out[i] = result.model_copy(update={ + "client_order_id": request.client_order_id, "type": request.type, + "time_in_force": request.time_in_force, "book": request.book, "trader": request.trader, + "tags": request.tags, + }) + return out + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one bet's unmatched part. One that can no longer be cancelled + (matched, lapsed, already cancelled) is read back as it stands.""" + [result] = await self.cancel_orders([order_id], market_id=market_id) + if isinstance(result, Exception): + raise result + return result + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel bets, sixty a request per Betfair market, each read back after + so the answer carries what matched first.""" + by_market: dict[str, list[str]] = {} + failed: dict[str, Exception] = {} + for order_id in dict.fromkeys(str(o) for o in order_ids): + try: + by_market.setdefault(await self._market_for(order_id, market_id), []).append(order_id) + except OrderNotFound as exc: + failed[order_id] = exc + for market, bets in by_market.items(): + for start in range(0, len(bets), CANCEL_PER_REQUEST): + chunk = bets[start:start + CANCEL_PER_REQUEST] + body = {"marketId": market, "instructions": [{"betId": bet} for bet in chunk]} + execution = await self._call("cancelOrders", body, kind="write", priority=Priority.HIGH) or {} + for report in execution.get("instructionReports") or []: + bet = str((report.get("instruction") or {}).get("betId") or "") + code = report.get("errorCode") + if report.get("status") != "SUCCESS" and code not in (None, "BET_TAKEN_OR_LAPSED"): + failed[bet] = _failure(report, execution, what="cancelOrders") + wanted = [str(o) for o in order_ids if str(o) not in failed] + orders = {o.id: o for o in await self._orders_by_id(wanted)} + out: list[Order | Exception] = [] + for order_id in (str(o) for o in order_ids): + if order_id in failed: + out.append(failed[order_id]) + elif order_id in orders: + out.append(orders[order_id]) + else: + out.append(OrderNotFound(f"{VENUE}: no bet {order_id}")) + return out + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int | None: + """Every unmatched bet on a runner, a Betfair market (`betfair:1.234`) + or the whole account. Returns how many Betfair reports cancelling.""" + if market_id and "-" in ids.native(VENUE, market_id): + bets = [o.id for o in await self.fetch_open_orders(market_id=market_id)] + if not bets: + return 0 + results = await self.cancel_orders(bets, market_id=market_id) + return sum(1 for r in results if isinstance(r, Order)) + body = {"marketId": _market_of(market_id)} if market_id else {} + execution = await self._call("cancelOrders", body, kind="write", priority=Priority.HIGH) or {} + if execution.get("status") not in (None, "SUCCESS", "PROCESSED_WITH_ERRORS"): + raise error_of(execution.get("errorCode"), what="cancelOrders", body=execution) + reports = execution.get("instructionReports") + if reports is None: + return None + return sum(1 for r in reports if isinstance(r, dict) and r.get("status") == "SUCCESS") + + async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order: + """A smaller quantity (a partial cancel, which keeps the bet's place in + the queue) and a new price (`replaceOrders`: the unmatched part placed + again at the new odds, as a new bet with a new id). `quantity` is the + order's new total, filled contracts included; it can only go down. + `update_persistence` changes what happens to it at the off.""" + if request.time_in_force is not None or request.expires_at is not None: + raise InvalidOrder(f"{VENUE}: a bet's time in force cannot be edited; cancel and place again") + current = current or await self.fetch_order(request.order_id) + if current.status not in (OrderStatus.OPEN, OrderStatus.PENDING): + raise InvalidOrder(f"{VENUE}: bet {request.order_id} has nothing unmatched to edit") + market = _market_of(current.market_id) + info = current.info + odds, _ = _price_size(info) + odds = odds or (ONE / current.price if current.price else ZERO) + remaining_stake = D(info.get("sizeRemaining")) if info.get("sizeRemaining") is not None \ + else (current.remaining or ZERO) / odds + order = current + if request.quantity is not None: + left = D(request.quantity) - current.filled + if left <= 0: + raise InvalidOrder(f"{VENUE}: the new quantity leaves nothing unmatched; cancel the bet instead") + new_stake = (left / odds).quantize(PENNY, rounding=ROUND_DOWN) + if new_stake > remaining_stake: + raise InvalidOrder(f"{VENUE}: Betfair cannot raise a bet's stake; place another bet for the rest") + if new_stake < remaining_stake: + reduction = remaining_stake - new_stake + body = {"marketId": market, "instructions": [{"betId": current.id, "sizeReduction": _number(reduction)}]} + await self._edit("cancelOrders", body) + order = (await self.fetch_order(current.id)).model_copy(update={"queue_priority_preserved": True}) + if request.price is not None: + new_odds = odds_for(current.side, request.price) + if new_odds != odds: + body = {"marketId": market, "instructions": [{"betId": current.id, "newPrice": _number(new_odds)}]} + execution = await self._edit("replaceOrders", body) + report = (execution.get("instructionReports") or [{}])[0] + placed = report.get("placeInstructionReport") or {} + if placed.get("status") != "SUCCESS" or not placed.get("betId"): + raise _failure(placed or report, execution, what="replaceOrders") + order = order_of_report(placed, market_id=market, account=self.account, currency=self._currency) + self._markets[order.id] = market + order = order.model_copy(update={ + "queue_priority_preserved": False, + "info": {**order.info, "replaced": current.id}, + }) + return order.model_copy(update={"client_order_id": current.client_order_id}) + + async def update_persistence(self, order_id: str, persistence: str, *, market_id: str | None = None) -> Order: + """What happens to a bet's unmatched part when the market turns in play + (`updateOrders`): `LAPSE`, `PERSIST` or `MARKET_ON_CLOSE`. Changes no + exposure, and keeps the bet's place in the queue.""" + wanted = persistence_of({"persistence": persistence}) + market = await self._market_for(order_id, market_id) + body = {"marketId": market, "instructions": [{"betId": str(order_id), "newPersistenceType": wanted}]} + await self._edit("updateOrders", body) + return await self.fetch_order(order_id) + + async def _edit(self, operation: str, body: dict[str, Any]) -> dict[str, Any]: + execution = await self._call(operation, body, kind="write") or {} + if execution.get("status") == "SUCCESS": + return execution + report = (execution.get("instructionReports") or [{}])[0] + raise _failure(report, execution, what=operation) + + async def fetch_order(self, order_id: str) -> Order: + """One bet by id: among the current orders, then, once it has left + them, among the cancelled, lapsed, settled and voided bets.""" + found = await self._orders_by_id([str(order_id)]) + if found: + return found[0] + raise OrderNotFound(f"{VENUE}: no bet {order_id}") + + async def fetch_order_by_client_id(self, client_order_id: str) -> Order: + """The current bet placed with this client order id (its `customerOrderRef`).""" + found = await self._current({"customerOrderRefs": [client_order_id], "orderProjection": "ALL"}) + if not found: + raise OrderNotFound(f"{VENUE}: no current bet with client order id {client_order_id}") + return found[0] + + async def _orders_by_id(self, order_ids: list[str]) -> list[Order]: + out: list[Order] = [] + # No bet ids would ask for every bet on the account. + order_ids = [o for o in order_ids if o] + for start in range(0, len(order_ids), MAX_IDS): + chunk = order_ids[start:start + MAX_IDS] + current = await self._current({"betIds": chunk, "orderProjection": "ALL"}) + out += current + missing = [o for o in chunk if o not in {order.id for order in current}] + for bet_status in CLEARED_STATUSES: + if not missing: + break + rows, _ = await self._cleared({"betStatus": bet_status, "betIds": missing}, pages=1) + cleared = [order_of_cleared({**row, "betStatus": bet_status}, account=self.account, + currency=self._currency) for row in rows] + out += cleared + missing = [o for o in missing if o not in {order.id for order in cleared}] + for order in out: + self._markets.setdefault(order.id, _market_of(order.market_id)) + return out + + async def _current_rows( + self, params: dict[str, Any], *, offset: int = 0, pages: int = MAX_PAGES, + ) -> tuple[list[dict[str, Any]], int | None]: + """Current order rows from `offset`, page by page; and the next offset + when more are left.""" + rows: list[dict[str, Any]] = [] + for _ in range(pages): + raw = await self._call("listCurrentOrders", {**params, "fromRecord": offset, "recordCount": PAGE_SIZE}) or {} + page = [r for r in raw.get("currentOrders") or [] if isinstance(r, dict)] + rows += page + offset += len(page) + if not page or not raw.get("moreAvailable"): + return rows, None + return rows, offset + + async def _current(self, params: dict[str, Any]) -> list[Order]: + rows, _ = await self._current_rows(params) + return [order_of_current(row, account=self.account, currency=self._currency) for row in rows] + + async def _cleared( + self, params: dict[str, Any], *, offset: int = 0, pages: int = MAX_PAGES, + ) -> tuple[list[dict[str, Any]], int | None]: + rows: list[dict[str, Any]] = [] + for _ in range(pages): + raw = await self._call("listClearedOrders", {**params, "fromRecord": offset, "recordCount": PAGE_SIZE}) or {} + page = [r for r in raw.get("clearedOrders") or [] if isinstance(r, dict)] + rows += page + offset += len(page) + if not page or not raw.get("moreAvailable"): + return rows, None + return rows, offset + + def _scope(self, market_id: str | None) -> tuple[dict[str, Any], str | None]: + """Betfair's market filter for a runner or a Betfair market, and the + runner to keep when it is a runner.""" + if not market_id: + return {}, None + native = ids.native(VENUE, market_id) + if "-" in native: + market, selection, handicap = parse_runner_id(native) + return {"marketIds": [market]}, _runner_id(market, selection, handicap) + return {"marketIds": [native]}, None + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Every bet with an unmatched part, or every one on a runner or a Betfair market.""" + scope, runner = self._scope(market_id) + orders = await self._current({**scope, "orderProjection": "EXECUTABLE"}) + return [o for o in orders if runner is None or o.market_id == runner] + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of bets on unsettled markets, in any state: `open`, + `closed` (matched in full) or `canceled` (the rest cancelled, lapsed + or killed). `since` keeps bets placed since then.""" + projection = {None: "ALL", "open": "EXECUTABLE", "closed": "EXECUTION_COMPLETE", "canceled": "EXECUTION_COMPLETE"} + if status not in projection: + raise BadRequest(f"{VENUE}: status is open, closed or canceled") + scope, runner = self._scope(market_id) + params: dict[str, Any] = {**scope, "orderProjection": projection[status], "orderBy": "BY_PLACE_TIME", + "sortDir": "LATEST_TO_EARLIEST"} + if since is not None: + params["dateRange"] = {"from": iso(since)} + offset = _offset(cursor) + size = min(limit or PAGE_SIZE, PAGE_SIZE) + raw = await self._call("listCurrentOrders", {**params, "fromRecord": offset, "recordCount": size}) or {} + rows = [r for r in raw.get("currentOrders") or [] if isinstance(r, dict)] + orders = [order_of_current(row, account=self.account, currency=self._currency) for row in rows] + if status == "closed": + orders = [o for o in orders if o.status == OrderStatus.CLOSED] + elif status == "canceled": + orders = [o for o in orders if o.status in (OrderStatus.CANCELED, OrderStatus.EXPIRED)] + if runner is not None: + orders = [o for o in orders if o.market_id == runner] + following = offset + len(rows) + return Page(orders, next_cursor=str(following) if rows and raw.get("moreAvailable") else None) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """What has matched on this account's bets in unsettled markets, one + fill per bet at its average matched odds, newest match first. A bet + appears once it has stopped matching (filled, or its rest cancelled + or lapsed), so its fill does not change after it is read; one still + matching is on `BetfairUserStream` and in `fetch_open_orders`.""" + scope, runner = self._scope(market_id) + params: dict[str, Any] = {**scope, "orderProjection": "EXECUTION_COMPLETE", "orderBy": "BY_MATCH_TIME", + "sortDir": "LATEST_TO_EARLIEST"} + if order_id: + params = {"betIds": [str(order_id)], "orderProjection": "ALL"} + if since is not None: + params["dateRange"] = {"from": iso(since)} + rows, following = await self._current_rows(params, offset=_offset(cursor), pages=1) + fills = [f for row in rows if str(row.get("status")) == "EXECUTION_COMPLETE" or order_id + for f in [fill_of_current(row, account=self.account, currency=self._currency)] if f is not None] + if runner is not None: + fills = [f for f in fills if f.market_id == runner] + if since is not None: + fills = [f for f in fills if f.timestamp >= since] + fills.sort(key=lambda f: f.timestamp, reverse=True) + return Page(fills[:limit] if limit else fills, next_cursor=str(following) if following is not None else None) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """What is free to bet (`availableToBetBalance`), what open bets hold + (`exposure`, `locked`), and the two together (`total`).""" + raw = await self._call("getAccountFunds", {}, api=ACCOUNT_PATH) or {} + free = D(raw.get("availableToBetBalance")) + locked = abs(D(raw.get("exposure"))) + return Balance( + venue=VENUE, account=account or self.account, currency=await self.currency(), + total=free + locked, available=free, locked=locked, buying_power=free, + timestamp=int(time.time() * 1000), info=raw, + ) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """The account's matched bets on unsettled markets netted per runner on + the YES leg. `market_id` is a runner, `event_id` a Betfair market.""" + scope, runner = self._scope(market_id or event_id) + rows, _ = await self._current_rows({**scope, "orderProjection": "ALL"}) + positions = positions_of_orders(rows, account=self.account) + return [p for p in positions if p.market_id == runner] if market_id and runner else positions + + async def fetch_profit_and_loss( + self, market_ids: list[str], *, net_of_commission: bool = False, + ) -> list[dict[str, Any]]: + """Betfair's own profit and loss on open markets, if each runner wins + (`ifWin`) or loses (`ifLose`, on markets with several winners), as + `listMarketProfitAndLoss` returns it. Runner ids or Betfair market ids.""" + markets = list(dict.fromkeys(_market_of(m) for m in market_ids)) + return list(await self._call("listMarketProfitAndLoss", { + "marketIds": markets, "includeSettledBets": False, "includeBspBets": True, + "netOfCommission": net_of_commission, + }) or []) + + async def fetch_settlements( + self, *, market_id: str | None = None, since: int | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Settlement]: + """One page of settled bets, one settlement each, as Betfair orders them + by settled date. `since` keeps bets settled since then.""" + scope, runner = self._scope(market_id) + params: dict[str, Any] = {"betStatus": "SETTLED", **scope} + if since is not None: + params["settledDateRange"] = {"from": iso(since)} + rows, following = await self._cleared(params, offset=_offset(cursor), pages=1) + # Bet-level rows name their runner; a roll-up Betfair hoists above it does not. + settlements = [settlement_of(row, account=self.account) for row in rows if row.get("selectionId") is not None] + if runner is not None: + settlements = [s for s in settlements if s.market_id == runner] + return Page(settlements[:limit] if limit else settlements, + next_cursor=str(following) if following is not None else None) + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The commission on `quantity` contracts at the YES `price` if the bet + wins: the market's base rate on its profit. A `sell` lays the runner, + which wins `price` a contract when the runner loses. Makers pay the + same. Betfair charges on net winnings per market, so this is an + upper bound for one bet among others.""" + schedule = await asyncio.to_thread(self.catalog.fetch_fee_schedule, market_id) + paid = D(price) if side == Side.BUY else ONE - D(price) + fee = schedule.estimate(float(paid), float(quantity), taker=True) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, ids.native(VENUE, market_id)), side=side, + price=D(price), quantity=D(quantity), + taker_fee=D(fee) if fee is not None else None, maker_fee=D(fee) if fee is not None else None, + currency=await self.currency(), + info={"schedule": schedule.model_dump(), "market": _market_of(market_id), + "charged": "at settlement, on net winnings in the market, before the account's discount"}, + ) + + async def close(self) -> None: + await self.http.close() + if self._own_catalog: + self.catalog.close() + else: + self.session.close() + + +def _offset(cursor: str | None) -> int: + if not cursor: + return 0 + if not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + return int(cursor) + + +__all__ = [ + "BetfairTrading", "build_instruction", "order_of_current", "order_of_report", "order_of_cleared", + "fill_of_current", "positions_of_orders", "settlement_of", "odds_for", "stake_for", "check_minimum", +] diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..60cb2cf 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -45,9 +45,10 @@ when there is no API key), and optionally MYRIAD_API_KEY with MYRIAD_API_SECRET (HMAC-signed requests), MYRIAD_ACCOUNT_ADDRESS, MYRIAD_RPC_URL - Betfair BETFAIR_APP_KEY, BETFAIR_USERNAME, BETFAIR_PASSWORD, and - (market data) optionally BETFAIR_CERT_PATH / BETFAIR_KEY_PATH - (certificate login) and BETFAIR_JURISDICTION + Betfair BETFAIR_APP_KEY (a live key bets; a delayed one reads), + BETFAIR_USERNAME, BETFAIR_PASSWORD, and optionally + BETFAIR_CERT_PATH / BETFAIR_KEY_PATH (certificate + login) and BETFAIR_JURISDICTION Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market (market data) data needs no login; one gives undelayed quotes Novig NOVIG_KEY_ID, NOVIG_PRIVATE_KEY_PATH (a subaccount's trading @@ -380,7 +381,8 @@ def secrets(self) -> list[str]: class BetfairCredentials: """Betfair: the account's application key and its login. Every Betfair call, market data included, needs both: the key names the application, - the login's session token the account.""" + the login's session token the account. Betting needs a live key; a + delayed key reads market data and the account only.""" app_key: str = field(repr=False) """The application key (delayed or live) from the account's developer diff --git a/synpath/trading/init.py b/synpath/trading/init.py index c4da301..dd74b33 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -275,8 +275,9 @@ def ask_myriad(p: Prompter) -> dict[str, str]: def ask_betfair(p: Prompter) -> dict[str, str]: p.say("\nBetfair: your application key (the delayed key is free: developer.betfair.com, or the Accounts API") - p.say("visualiser) and your Betfair login. Every Betfair call, market data included, logs in. The key and the") - p.say("password are typed hidden. A client certificate (optional) switches to Betfair's certificate login.") + p.say("visualiser; betting needs the live key, which Betfair activates on request) and your Betfair login.") + p.say("Every Betfair call, market data included, logs in. The key and the password are typed hidden. A client") + p.say("certificate (optional) switches to Betfair's certificate login.") app_key = p.value("Application key (typed hidden)", secret=True, check=_required("The application key")) username = p.value("Betfair username", check=_required("The username")) password = p.value("Betfair password (typed hidden)", secret=True, check=_required("The password")) diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 2170ae7..0cee7d2 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -53,11 +53,12 @@ # so contracts take any size to a millionth. "sx_bet": {"default_tick": Decimal("0.00125"), "min_quantity": Decimal("0.000001"), "quantity_step": Decimal("0.000001"), "whole": False, "min_notional": Decimal("1")}, - # No order entry yet; written down for when there is. Prices are decimal - # odds on Betfair's ladder (1.01 to 1000), so the step in YES price runs - # from about 0.0097 near 1.01 to 0.00001 near 1000: the tick here is the - # finest, and an order must still land on the ladder in odds. Stakes are - # in the account's currency, to the penny, at least 1 (GBP) a bet. + # Prices are decimal odds on Betfair's CLASSIC ladder (1.01 to 1000), so + # the step in YES price runs from about 0.0097 near 1.01 to 0.00001 near + # 1000: the tick here is the finest, and the trading adapter snaps each + # order onto the ladder in odds. Stakes are in the account's currency, + # to the penny, at least 1 (GBP) a bet unless its payout is 10 or more, + # which the adapter checks; the notional here is the plain minimum. "betfair": {"default_tick": Decimal("0.00001"), "min_quantity": Decimal("0.01"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("1")}, # No order entry yet; written down for when there is. Prices are basis diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index a9d7c38..a9aacbc 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -53,9 +53,9 @@ def __getattr__(name: str): if name in ("MyriadMarketStream", "MyriadUserStream"): from . import myriad return getattr(myriad, name) - if name == "BetfairMarketStream": - from .betfair import BetfairMarketStream - return BetfairMarketStream + if name in ("BetfairMarketStream", "BetfairUserStream"): + from . import betfair + return getattr(betfair, name) if name == "SmarketsMarketStream": from .smarkets import SmarketsMarketStream return SmarketsMarketStream @@ -93,5 +93,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "BetfairUserStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", ] diff --git a/synpath/ws/betfair.py b/synpath/ws/betfair.py index 8159b26..8f9a165 100644 --- a/synpath/ws/betfair.py +++ b/synpath/ws/betfair.py @@ -1,4 +1,5 @@ -"""Betfair's Exchange Stream API: runner books and market status, live. +"""Betfair's Exchange Stream API: runner books and market status, and the +account's bets, matches and positions, live. Not a WebSocket. The stream is one TLS socket to `stream-api.betfair.com:443` carrying JSON messages, one per line, each ended by CRLF. `LineSocket` wraps @@ -33,12 +34,21 @@ books are still right, only late. The stream warns once when it sees it. Betfair publishes no individual trades, so there is no `watch_trades`. + +**The account** (`BetfairUserStream`) is the same protocol on its own +connection: `{"op": "orderSubscription"}` and `{"op": "ocm"}` order change +messages, per Betfair market (`oc`) and runner (`orc`). Each changed bet +(`uo`) is sent whole; the matched ladders per runner (`mb`, `ml`, `[odds, +stake]`, a stake of 0 deleting the point) are deltas. A new subscription's +image carries only bets still unmatched in part; a bet that finishes sends +its `EC` (execution complete) once, then leaves the stream. """ from __future__ import annotations import asyncio import logging import ssl +from collections import OrderedDict from dataclasses import dataclass, field from decimal import Decimal from typing import Any, Callable @@ -46,7 +56,11 @@ from .. import ids from ..base import Capability from ..errors import AuthenticationError -from .base import BookEvent, BookLevel, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms +from ..trading.types import OrderStatus +from .base import ( + BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, + now_ms, +) log = logging.getLogger("synpath.ws.betfair") @@ -162,7 +176,99 @@ def runner_id(market_id: str, key: tuple[int, float]) -> str: return ids.qualify(VENUE, runner_native_id(market_id, key[0], key[1])) -class BetfairMarketStream(Stream): +class _BetfairStream(Stream): + """One Exchange Stream API connection: the TLS socket, authentication and + Betfair's status messages. The market and the order streams are each + one subscription on their own connection.""" + + venue = VENUE + data_heartbeat = True + + def __init__( + self, *, credentials: Any = None, session: Any = None, host: str = STREAM_HOST, port: int = STREAM_PORT, + heartbeat_ms: int = 5000, open_connection: Callable[..., Any] | None = None, + ssl_context: ssl.SSLContext | None = None, **kwargs: Any, + ): + from ..betfair import session_from + + self.session = session or session_from(credentials) + if self.session is None: + raise AuthenticationError( + f"{VENUE}: the stream needs an account: set BETFAIR_APP_KEY, BETFAIR_USERNAME and " + f"BETFAIR_PASSWORD, or pass credentials= or session=" + ) + super().__init__(f"tls://{host}:{port}", **kwargs) + self.host, self.port = host, port + self.heartbeat_ms = heartbeat_ms + self._open_connection = open_connection or asyncio.open_connection + self._ssl = ssl_context or ssl.create_default_context() + self.authenticated = False + self.connection_id: str | None = None + self.initial_clk: str | None = None + self.clk: str | None = None + self._next_id = 0 + self._auth_id: int | None = None + self._sub_id: int | None = None + + # -- connection ----------------------------------------------------------- + + async def _open(self) -> Any: + reader, writer = await self._open_connection( + self.host, self.port, ssl=self._ssl, server_hostname=self.host, limit=LINE_LIMIT, + ) + return LineSocket(reader, writer) + + def _id(self) -> int: + self._next_id += 1 + return self._next_id + + async def on_connect(self) -> None: + self.authenticated = False + token = await asyncio.to_thread(self.session.token) + self._auth_id = self._id() + await self.send({"op": "authentication", "id": self._auth_id, "appKey": self.session.app_key, "session": token}) + + def on_disconnect(self) -> None: + self.authenticated = False + + async def resubscribe(self) -> None: + """Send the subscription, once authenticated.""" + + def subscribed(self) -> None: + """Betfair accepted the subscription.""" + + # -- status messages ------------------------------------------------------ + + def _on_status(self, message: dict[str, Any]) -> list[Event]: + if message.get("statusCode") == "SUCCESS": + if message.get("id") == self._auth_id: + self.authenticated = True + self._later(self.resubscribe()) + elif message.get("id") == self._sub_id: + self.subscribed() + return [] + code = str(message.get("errorCode") or "") + detail = f"{code}: {message.get('errorMessage') or ''}".strip(": ") + if code in APP_KEY_ERRORS: + self.status("failed", f"Betfair refused the application key for streaming ({detail})") + self._later(self.close()) + elif code in SESSION_ERRORS: + # The next connection logs in again. + self.session.invalidate() + self.status("error", f"session refused ({detail}); logging in again") + else: + self.status("error", f"Betfair refused: {detail}") + return [] + + def _later(self, coroutine: Any) -> asyncio.Task | None: + try: + return asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + coroutine.close() + return None + + +class BetfairMarketStream(_BetfairStream): """Runner books (best offers) and market status for Betfair markets, over the Exchange Stream API. Needs the account's application key and a session, as every Betfair call does. @@ -179,9 +285,7 @@ class BetfairMarketStream(Stream): ``` """ - venue = VENUE name = "market" - data_heartbeat = True has: dict[str, Capability] = { "watch_order_book": True, # Top of book from the best offers; no last traded price is subscribed. @@ -196,26 +300,16 @@ def __init__( ladder_levels: int = 3, conflate_ms: int | None = None, heartbeat_ms: int = 5000, delayed: bool = True, open_connection: Callable[..., Any] | None = None, ssl_context: ssl.SSLContext | None = None, **kwargs: Any, ): - from ..betfair import session_from - - self.session = session or session_from(credentials) - if self.session is None: - raise AuthenticationError( - f"{VENUE}: the stream needs an account: set BETFAIR_APP_KEY, BETFAIR_USERNAME and " - f"BETFAIR_PASSWORD, or pass credentials= or session=" - ) if not 1 <= ladder_levels <= MAX_LADDER_LEVELS: raise ValueError(f"ladder_levels must be 1 to {MAX_LADDER_LEVELS}") # A delayed stream can be silent for a whole conflation window, so # silence means a dead socket only after one window and some slack. kwargs.setdefault("idle_timeout", DELAYED_CONFLATE_MS / 1000 + 60 if delayed else max(60.0, heartbeat_ms * 3 / 1000)) - super().__init__(f"tls://{host}:{port}", **kwargs) - self.host, self.port = host, port + super().__init__(credentials=credentials, session=session, host=host, port=port, heartbeat_ms=heartbeat_ms, + open_connection=open_connection, ssl_context=ssl_context, **kwargs) self.ladder_levels = ladder_levels - self.conflate_ms, self.heartbeat_ms = conflate_ms, heartbeat_ms + self.conflate_ms = conflate_ms self.delayed = delayed - self._open_connection = open_connection or asyncio.open_connection - self._ssl = ssl_context or ssl.create_default_context() self.markets: dict[str, MarketCache] = {} """Betfair market id -> its cache, kept across reconnects for the resubscribe delta.""" self.books: dict[str, LocalBook] = {} @@ -227,38 +321,13 @@ def __init__( """Betfair market ids the subscription carries.""" self.whole_markets: set[str] = set() """Betfair markets watched as a whole: every runner's book is kept.""" - self.authenticated = False - self.connection_id: str | None = None - self.initial_clk: str | None = None - self.clk: str | None = None self._clk_for: frozenset[str] = frozenset() """The market set `clk` belongs to: a resubscribe only resumes that one.""" - self._next_id = 0 - self._auth_id: int | None = None - self._sub_id: int | None = None self._last_state: dict[str, tuple[str, str | None]] = {} self._warned_delayed = False - # -- connection ----------------------------------------------------------- - - async def _open(self) -> Any: - reader, writer = await self._open_connection( - self.host, self.port, ssl=self._ssl, server_hostname=self.host, limit=LINE_LIMIT, - ) - return LineSocket(reader, writer) - - def _id(self) -> int: - self._next_id += 1 - return self._next_id - - async def on_connect(self) -> None: - self.authenticated = False - token = await asyncio.to_thread(self.session.token) - self._auth_id = self._id() - await self.send({"op": "authentication", "id": self._auth_id, "appKey": self.session.app_key, "session": token}) - def on_disconnect(self) -> None: - self.authenticated = False + super().on_disconnect() for book in self.books.values(): book.invalidate() @@ -388,26 +457,8 @@ def handle(self, message: Any) -> list[Event]: return self._on_market_changes(message) return [] - def _on_status(self, message: dict[str, Any]) -> list[Event]: - if message.get("statusCode") == "SUCCESS": - if message.get("id") == self._auth_id: - self.authenticated = True - self._later(self.resubscribe()) - elif message.get("id") == self._sub_id: - self.status("subscribed", f"{len(self.wanted)} markets") - return [] - code = str(message.get("errorCode") or "") - detail = f"{code}: {message.get('errorMessage') or ''}".strip(": ") - if code in APP_KEY_ERRORS: - self.status("failed", f"Betfair refused the application key for streaming ({detail})") - self._later(self.close()) - elif code in SESSION_ERRORS: - # The next connection logs in again. - self.session.invalidate() - self.status("error", f"session refused ({detail}); logging in again") - else: - self.status("error", f"Betfair refused: {detail}") - return [] + def subscribed(self) -> None: + self.status("subscribed", f"{len(self.wanted)} markets") def _on_market_changes(self, message: dict[str, Any]) -> list[Event]: if message.get("initialClk"): @@ -531,13 +582,6 @@ def _status_changes(self, market: str, definition: dict[str, Any]) -> list[Event )) return events - def _later(self, coroutine: Any) -> asyncio.Task | None: - try: - return asyncio.get_running_loop().create_task(coroutine) - except RuntimeError: - coroutine.close() - return None - def changed(old: tuple[BookLevel, ...], new: tuple[BookLevel, ...]) -> tuple[BookLevel, ...]: """The levels that differ between two sides, a removed one at size 0.""" @@ -546,3 +590,309 @@ def changed(old: tuple[BookLevel, ...], new: tuple[BookLevel, ...]) -> tuple[Boo out = [BookLevel(price, size) for price, size in after.items() if before.get(price) != size] out += [BookLevel(price, Decimal(0)) for price in before if price not in after] return tuple(out) + + +# --------------------------------------------------------------------------- +# The account's orders +# --------------------------------------------------------------------------- + +BET_MEMORY = 8192 +"""Bets whose matched amount the order stream remembers, most recent first.""" + +STREAM_SIDES = {"B": "BACK", "L": "LAY"} +STREAM_STATUS = {"E": "EXECUTABLE", "EC": "EXECUTION_COMPLETE"} +STREAM_PERSISTENCE = {"L": "LAPSE", "P": "PERSIST", "MOC": "MARKET_ON_CLOSE"} +STREAM_ORDER_TYPES = {"L": "LIMIT", "MOC": "MARKET_ON_CLOSE", "LOC": "LIMIT_ON_CLOSE"} + + +def current_row(order: dict[str, Any], *, market: str, selection: Any, handicap: Any) -> dict[str, Any]: + """A stream order (`uo`) in the shape `listCurrentOrders` reports, so the + one reader turns both into an `Order`.""" + from ..types import iso + + def when(key: str) -> str | None: + return iso(int(order[key])) if order.get(key) else None + + return { + "betId": str(order.get("id") or ""), "marketId": market, "selectionId": int(selection), + "handicap": float(handicap or 0), "priceSize": {"price": order.get("p"), "size": order.get("s")}, + "bspLiability": order.get("bsp"), "side": STREAM_SIDES.get(str(order.get("side")), order.get("side")), + "status": STREAM_STATUS.get(str(order.get("status")), order.get("status")), + "persistenceType": STREAM_PERSISTENCE.get(str(order.get("pt")), order.get("pt")), + "orderType": STREAM_ORDER_TYPES.get(str(order.get("ot")), order.get("ot")), + "placedDate": when("pd"), "matchedDate": when("md"), "cancelledDate": when("cd"), "lapsedDate": when("ld"), + "lapseStatusReasonCode": order.get("lsrc"), + "averagePriceMatched": order.get("avp") or 0, "sizeMatched": order.get("sm") or 0, + "sizeRemaining": order.get("sr") or 0, "sizeLapsed": order.get("sl") or 0, + "sizeCancelled": order.get("sc") or 0, "sizeVoided": order.get("sv") or 0, + "regulatorCode": order.get("rc"), "regulatorAuthCode": order.get("rac"), + "customerOrderRef": order.get("rfo") or None, "customerStrategyRef": order.get("rfs") or None, + } + + +@dataclass +class RunnerPosition: + backs: dict[Decimal, Decimal] = field(default_factory=dict) + """`mb`: matched backing stake by matched odds. YES contracts.""" + lays: dict[Decimal, Decimal] = field(default_factory=dict) + """`ml`: matched laying (backer's) stake by matched odds. NO contracts.""" + + +def apply_matches(ladder: dict[Decimal, Decimal], updates: Any) -> None: + """`[odds, stake]` points onto a matched ladder; stake 0 removes the point.""" + for odds, stake in updates or []: + price, size = Decimal(str(odds)), Decimal(str(stake)) + if size == 0: + ladder.pop(price, None) + else: + ladder[price] = size + + +class BetfairUserStream(_BetfairStream): + """The account's bets as they change, what matches on them, and the + matched position on each runner, from the Exchange Stream API's order + subscription. Needs the account's application key and a session, as + every Betfair call does (it can share `BetfairTrading.session`). + + Betfair sends each changed bet in full, so every change is an + `OrderEvent`; what newly matched on it (its matched stake going up) is + a `FillEvent` at the odds the increase averaged, with the bet id and + its matched stake so far as the fill's id. Betfair's matched ladders per + runner (`mb`, `ml`) are the `PositionEvent`s. The first image after a + connection carries only the bets still unmatched in part, and is the + baseline the fills are measured from; a reconnect resumes from the last + clock when Betfair can (`RESUB_DELTA`) and is reported with + `reconcile_required`. + + ```python + async with BetfairUserStream(session=trading.session) as stream: + await stream.watch_orders() + async for event in stream: + ... + ``` + """ + + name = "user" + private = True + has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True, "watch_positions": True} + + def __init__(self, *, credentials: Any = None, session: Any = None, account_name: str = "default", + strategy_refs: list[str] | None = None, **kwargs: Any): + """`strategy_refs` limits the bets streamed to those placed with these + `customerStrategyRef`s (the overall position is not filtered).""" + kwargs.setdefault("idle_timeout", max(60.0, kwargs.get("heartbeat_ms", 5000) * 3 / 1000)) + super().__init__(credentials=credentials, session=session, **kwargs) + from ..trading.types import Account + + self.account = Account(venue=VENUE, name=account_name) + self.strategy_refs = list(strategy_refs or []) + self.kinds: set[str] = set() + """What is watched: orders, fills, positions.""" + self.only: set[str] | None = None + """Betfair market ids or runner ids passed on; `None` for every one.""" + self.orders: dict[str, Any] = {} + """Bet id -> the `Order` as last reported, while it has an unmatched part.""" + self.matched: OrderedDict[str, tuple[Decimal, Decimal]] = OrderedDict() + """Bet id -> (matched stake, average matched odds) as last reported, + the most recent `BET_MEMORY` bets.""" + self.positions: dict[tuple[str, tuple[int, float]], RunnerPosition] = {} + self._degraded = False + + # -- intents -------------------------------------------------------------- + + async def watch_orders(self, market_ids: list[str] | None = None) -> None: + """Every bet on the account, or those on these runners or Betfair + markets. Betfair streams the whole account; the rest is dropped here.""" + await self._watch("orders", market_ids) + + async def watch_my_trades(self, market_ids: list[str] | None = None) -> None: + """What matches on the account's bets, read off their changes.""" + await self._watch("fills", market_ids) + + async def watch_positions(self, market_ids: list[str] | None = None) -> None: + """The matched position on each runner the account has bet on.""" + await self._watch("positions", market_ids) + + async def _watch(self, kind: str, market_ids: list[str] | None) -> None: + if market_ids is None: + self.only = None + elif self.only is not None or not self.kinds: + self.only = (self.only or set()) | {ids.qualify(VENUE, ids.native(VENUE, m)) for m in market_ids} + new = kind not in self.kinds + self.kinds.add(kind) + if new and len(self.kinds) == 1: + await self.resubscribe() + + def _wanted(self, market: str, synpath_id: str) -> bool: + return self.only is None or synpath_id in self.only or f"{VENUE}:{market}" in self.only + + # -- subscription --------------------------------------------------------- + + def subscription(self) -> dict[str, Any]: + """The order subscription, resuming from the last clock when there is one.""" + order_filter: dict[str, Any] = {"includeOverallPosition": True} + if self.strategy_refs: + order_filter["customerStrategyRefs"] = self.strategy_refs + frame: dict[str, Any] = { + "op": "orderSubscription", "id": self._id(), "orderFilter": order_filter, + "segmentationEnabled": True, "heartbeatMs": self.heartbeat_ms, + } + if self.clk and self.initial_clk: + frame["initialClk"], frame["clk"] = self.initial_clk, self.clk + return frame + + async def resubscribe(self) -> None: + if not self.authenticated or not self.kinds: + return + frame = self.subscription() + self._sub_id = frame["id"] + await self.send(frame) + + def subscribed(self) -> None: + self.status("subscribed", "orders") + + # -- messages ------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + op = message.get("op") + if op == "connection": + self.connection_id = message.get("connectionId") + return [] + if op == "status": + return self._on_status(message) + if op == "ocm": + return self._on_order_changes(message) + return [] + + def _on_order_changes(self, message: dict[str, Any]) -> list[Event]: + if message.get("initialClk"): + self.initial_clk = str(message["initialClk"]) + if message.get("clk"): + self.clk = str(message["clk"]) + self._health(message.get("status")) + if message.get("ct") == "HEARTBEAT": + return [] + image = message.get("ct") == "SUB_IMAGE" + if image and message.get("segmentType") in (None, "SEG_START"): + self.orders.clear() + self.positions.clear() + events: list[Event] = [] + for change in message.get("oc") or []: + if isinstance(change, dict) and change.get("id"): + events += self._on_market(change, image=image) + return events + + def _health(self, status: Any) -> None: + """Betfair marks a change message 503 while its order data may be + incomplete, and drops the mark once it has caught up.""" + if status == 503 and not self._degraded: + self._degraded = True + self.status("error", "Betfair says the order stream is delayed (503): bets may be missing until it " + "catches up", reconcile=True) + elif status is None and self._degraded: + self._degraded = False + self.status("resynced", "the order stream caught up") + + def _on_market(self, change: dict[str, Any], *, image: bool) -> list[Event]: + market = str(change["id"]) + if change.get("fullImage"): + for key in [k for k in self.positions if k[0] == market]: + self.positions.pop(key, None) + events: list[Event] = [] + for runner_change in change.get("orc") or []: + if isinstance(runner_change, dict) and runner_change.get("id") is not None: + events += self._on_runner(market, runner_change, image=image or bool(change.get("fullImage"))) + if change.get("closed"): + for key in [k for k in self.positions if k[0] == market]: + self.positions.pop(key, None) + return events + + def _on_runner(self, market: str, change: dict[str, Any], *, image: bool) -> list[Event]: + from ..trading.betfair import order_of_current + + key = runner_key(change["id"], change.get("hc")) + synpath_id = runner_id(market, key) + wanted = self._wanted(market, synpath_id) + events: list[Event] = [] + for row in change.get("uo") or []: + if not isinstance(row, dict) or not row.get("id"): + continue + current = current_row(row, market=market, selection=key[0], handicap=key[1]) + order = order_of_current(current, account=self.account) + order = order.model_copy(update={ + "created_at": int(row["pd"]) if row.get("pd") else order.created_at, + "updated_at": int(row.get("md") or row.get("cd") or row.get("ld") or 0) or order.updated_at, + }) + fill = self._fill(order, row, baseline=image) + if order.status in (OrderStatus.OPEN, OrderStatus.PENDING): + self.orders[order.id] = order + else: + # Its matched amount is kept: a runner removal or a void can + # still change a finished bet, and that is not a new match. + self.orders.pop(order.id, None) + if not wanted: + continue + if fill is not None and "fills" in self.kinds: + events.append(FillEvent(venue=VENUE, fill=fill)) + if "orders" in self.kinds: + if fill is not None: + order = order.model_copy(update={"last_fill_price": fill.price, "last_fill_quantity": fill.quantity}) + events.append(OrderEvent(venue=VENUE, order=order, native=str(row.get("status") or ""))) + if "mb" in change or "ml" in change or change.get("fullImage"): + position = self.positions.setdefault((market, key), RunnerPosition()) + if change.get("fullImage"): + position.backs.clear() + position.lays.clear() + apply_matches(position.backs, change.get("mb")) + apply_matches(position.lays, change.get("ml")) + if wanted and "positions" in self.kinds: + events.append(PositionEvent(venue=VENUE, position=self._position(synpath_id, position))) + return events + + def _fill(self, order: Any, row: dict[str, Any], *, baseline: bool) -> Any: + """What newly matched on a bet since it was last seen, or `None`. A bet + first seen in an image is the baseline; one first seen in an update + matched from nothing.""" + from ..trading.types import Fill, Liquidity + + matched, average = Decimal(str(row.get("sm") or 0)), Decimal(str(row.get("avp") or 0)) + before = self.matched.get(order.id) + self.matched[order.id] = (matched, average) + self.matched.move_to_end(order.id) + while len(self.matched) > BET_MEMORY: + self.matched.popitem(last=False) + if before is None and baseline: + return None + old_stake, old_odds = before or (Decimal(0), Decimal(0)) + stake = matched - old_stake + contracts = matched * average - old_stake * old_odds + if stake <= 0 or contracts <= 0: + return None + return Fill( + id=f"{order.id}:{matched.normalize()}", order_id=order.id, client_order_id=order.client_order_id, + venue=VENUE, account=self.account, market_id=order.market_id, side=order.side, + price=(stake / contracts).quantize(PRICE_PLACES), quantity=contracts, liquidity=Liquidity.UNKNOWN, + timestamp=int(row.get("md") or 0) or now_ms(), + info={"bet_id": order.id, "size_matched": str(matched), "average_odds": str(average)}, + ) + + def _position(self, synpath_id: str, position: RunnerPosition) -> Any: + from ..trading.types import Position, PositionSide + + yes = sum((odds * stake for odds, stake in position.backs.items()), Decimal(0)) + no = sum((odds * stake for odds, stake in position.lays.items()), Decimal(0)) + stake = sum(position.backs.values(), Decimal(0)) + sum(position.lays.values(), Decimal(0)) + margin = sum(position.backs.values(), Decimal(0)) + sum( + (stake_ * (odds - 1) for odds, stake_ in position.lays.items()), Decimal(0)) + net, traded = yes - no, yes + no + return Position( + venue=VENUE, account=self.account, market_id=synpath_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), inventory_yes=yes, inventory_no=no, + entry_price=(stake / traded).quantize(PRICE_PLACES) if traded else None, margin=margin, + timestamp=now_ms(), + info={"matched_backs": [[str(o), str(s)] for o, s in sorted(position.backs.items())], + "matched_lays": [[str(o), str(s)] for o, s in sorted(position.lays.items())]}, + ) diff --git a/tests/samples/betfair_trading.json b/tests/samples/betfair_trading.json new file mode 100644 index 0000000..9eaee86 --- /dev/null +++ b/tests/samples/betfair_trading.json @@ -0,0 +1,465 @@ +{ + "place_lay_request": { + "marketId": "1.109850906", + "instructions": [ + { + "selectionId": "237486", + "handicap": "0", + "side": "LAY", + "orderType": "LIMIT", + "limitOrder": { + "size": "2", + "price": "3", + "persistenceType": "LAPSE" + } + } + ] + }, + "place_lay": { + "marketId": "1.109850906", + "instructionReports": [ + { + "instruction": { + "selectionId": 237486, + "handicap": 0, + "limitOrder": { + "size": 2, + "price": 3, + "persistenceType": "LAPSE" + }, + "orderType": "LIMIT", + "side": "LAY" + }, + "betId": "31242604945", + "placedDate": "2013-10-30T14:22:47.000Z", + "averagePriceMatched": 0, + "sizeMatched": 0, + "status": "SUCCESS" + } + ], + "status": "SUCCESS" + }, + "place_persist": { + "status": "SUCCESS", + "marketId": "1.240636817", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "selectionId": 47999, + "handicap": 0.0, + "limitOrder": { + "size": 2.0, + "price": 10.0, + "persistenceType": "PERSIST" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "383626336337", + "placedDate": "2025-04-02T13:23:40.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0, + "orderStatus": "EXECUTABLE" + } + ] + }, + "place_fok_killed": { + "status": "SUCCESS", + "marketId": "1.126124422", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "selectionId": 10590221, + "handicap": 0.0, + "limitOrder": { + "size": 5.0, + "price": 21.0, + "minFillSize": 5.0, + "timeInForce": "FILL_OR_KILL" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "72666364933", + "placedDate": "2016-08-12T10:45:15.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0 + } + ] + }, + "place_fok_partial": { + "status": "SUCCESS", + "marketId": "1.126124422", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "selectionId": 10590221, + "handicap": 0.0, + "limitOrder": { + "size": 5.0, + "price": 21.0, + "minFillSize": 3.0, + "timeInForce": "FILL_OR_KILL" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "72666433304", + "placedDate": "2016-08-12T10:47:42.000Z", + "averagePriceMatched": 21.32267441860465, + "sizeMatched": 3.4400000000000004 + } + ] + }, + "place_version_rejected": { + "status": "FAILURE", + "errorCode": "BET_ACTION_ERROR", + "marketId": "1.192357130", + "instructionReports": [ + { + "status": "FAILURE", + "errorCode": "BET_TAKEN_OR_LAPSED", + "instruction": { + "selectionId": 3531817, + "handicap": 0.0, + "limitOrder": { + "size": 2.0, + "price": 1000.0, + "persistenceType": "LAPSE" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "253638292039", + "placedDate": "2021-12-15T11:05:58.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0, + "orderStatus": "EXECUTION_COMPLETE" + } + ] + }, + "current_orders": { + "currentOrders": [ + { + "betId": "45496907354", + "marketId": "1.117020524", + "selectionId": 9170340, + "handicap": 0.0, + "priceSize": { + "price": 10.0, + "size": 5.0 + }, + "bspLiability": 0.0, + "side": "BACK", + "status": "EXECUTABLE", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "placedDate": "2015-01-22T13:01:53.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0, + "sizeRemaining": 5.0, + "sizeLapsed": 0.0, + "sizeCancelled": 0.0, + "sizeVoided": 0.0, + "regulatorCode": "GIBRALTAR REGULATOR" + } + ], + "moreAvailable": false + }, + "cleared_orders": { + "clearedOrders": [ + { + "eventTypeId": "1", + "eventId": "28746818", + "marketId": "1.144281274", + "placedDate": "2018-05-29T10:48:50.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "betOutcome": "LOST", + "settledDate": "2018-05-29T19:12:41.000Z", + "lastMatchedDate": "2018-05-29T15:25:26.000Z", + "betCount": 8, + "commission": 0.0, + "priceReduced": false, + "profit": -16.0 + }, + { + "eventTypeId": "2", + "eventId": "28741289", + "marketId": "1.144157120", + "selectionId": 2830908, + "handicap": 0.0, + "betId": "126613673925", + "placedDate": "2018-05-25T08:26:20.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "WON", + "priceRequested": 1.28, + "settledDate": "2018-05-25T10:19:02.000Z", + "lastMatchedDate": "2018-05-25T08:26:40.000Z", + "betCount": 1, + "commission": 0.03, + "priceMatched": 1.28, + "priceReduced": false, + "sizeSettled": 2.0, + "profit": 0.56 + }, + { + "eventTypeId": "7", + "eventId": "28739568", + "marketId": "1.144114832", + "selectionId": 11295111, + "handicap": 0.0, + "placedDate": "2018-05-23T21:27:52.000Z", + "orderType": "LIMIT", + "betOutcome": "WON", + "settledDate": "2018-05-24T13:06:01.000Z", + "lastMatchedDate": "2018-05-24T13:04:24.000Z", + "betCount": 2, + "commission": 0.11, + "priceReduced": true, + "profit": 2.14, + "customerStrategyRef": "HedgerPro_216" + }, + { + "eventTypeId": "7", + "eventId": "28739518", + "marketId": "1.144114517", + "selectionId": 8912176, + "handicap": 0.0, + "betId": "126486489864", + "placedDate": "2018-05-23T15:31:57.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "LOST", + "priceRequested": 11.5, + "settledDate": "2018-05-23T17:12:22.000Z", + "lastMatchedDate": "2018-05-23T15:31:57.000Z", + "betCount": 1, + "commission": 0.0, + "priceMatched": 11.5, + "priceReduced": false, + "sizeSettled": 2.65, + "profit": -2.65 + }, + { + "eventTypeId": "1", + "eventId": "28724382", + "marketId": "1.143781550", + "selectionId": 2081063, + "handicap": 0.0, + "placedDate": "2018-05-21T15:43:12.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "LOST", + "priceRequested": 4.4, + "settledDate": "2018-05-21T20:53:27.000Z", + "lastMatchedDate": "2018-05-21T15:43:12.000Z", + "betCount": 3, + "commission": 0.0, + "priceMatched": 4.4, + "priceReduced": false, + "sizeSettled": 6.0, + "profit": -6.0 + }, + { + "eventTypeId": "1", + "eventId": "28717428", + "marketId": "1.143588270", + "selectionId": 18565, + "handicap": 0.0, + "betId": "125772703658", + "placedDate": "2018-05-16T14:48:53.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "WON", + "priceRequested": 2.18, + "settledDate": "2018-05-16T20:43:37.000Z", + "lastMatchedDate": "2018-05-16T14:48:53.000Z", + "betCount": 1, + "commission": 0.12, + "priceMatched": 2.18, + "priceReduced": false, + "sizeSettled": 2.0, + "profit": 2.36 + }, + { + "eventTypeId": "1", + "eventId": "28722989", + "marketId": "1.143732676", + "selectionId": 198689, + "handicap": 0.0, + "placedDate": "2018-05-14T13:22:56.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "betOutcome": "WON", + "priceRequested": 1.42, + "settledDate": "2018-05-14T18:22:07.000Z", + "lastMatchedDate": "2018-05-14T13:22:56.000Z", + "betCount": 2, + "commission": 0.13, + "priceMatched": 1.42, + "priceReduced": false, + "sizeSettled": 6.0, + "profit": 2.52 + } + ], + "moreAvailable": false + }, + "account_funds": { + "availableToBetBalance": 95.5, + "exposure": -4.5, + "retainedCommission": 0.0, + "exposureLimit": -10000.0, + "discountRate": 0.0, + "pointsBalance": 10, + "wallet": "UK" + }, + "account_details": { + "currencyCode": "GBP", + "firstName": "Joe", + "lastName": "Bloggs", + "localeCode": "en", + "region": "GBR", + "timezone": "GMT", + "discountRate": 0.0, + "pointsBalance": 10, + "countryCode": "GB" + }, + "cancel": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354" + }, + "sizeCancelled": 5.0, + "cancelledDate": "2015-01-22T13:05:01.000Z" + } + ] + }, + "cancel_taken": { + "status": "FAILURE", + "errorCode": "BET_ACTION_ERROR", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "FAILURE", + "errorCode": "BET_TAKEN_OR_LAPSED", + "instruction": { + "betId": "45496907354" + } + } + ] + }, + "cancel_partial": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354", + "sizeReduction": 2.0 + }, + "sizeCancelled": 2.0, + "cancelledDate": "2015-01-22T13:05:01.000Z" + } + ] + }, + "replace": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "cancelInstructionReport": { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354" + }, + "sizeCancelled": 5.0, + "cancelledDate": "2015-01-22T13:06:00.000Z" + }, + "placeInstructionReport": { + "status": "SUCCESS", + "instruction": { + "selectionId": 9170340, + "handicap": 0.0, + "limitOrder": { + "size": 5.0, + "price": 12.0, + "persistenceType": "LAPSE" + }, + "orderType": "LIMIT", + "side": "BACK" + }, + "betId": "45496907999", + "placedDate": "2015-01-22T13:06:00.000Z", + "averagePriceMatched": 0.0, + "sizeMatched": 0.0 + } + } + ] + }, + "update": { + "status": "SUCCESS", + "marketId": "1.117020524", + "instructionReports": [ + { + "status": "SUCCESS", + "instruction": { + "betId": "45496907354", + "newPersistenceType": "PERSIST" + } + } + ] + }, + "profit_and_loss": [ + { + "marketId": "1.117020524", + "profitAndLosses": [ + { + "selectionId": 9170340, + "ifWin": 45.0 + }, + { + "selectionId": 9170341, + "ifWin": -5.0 + } + ] + } + ], + "cleared_cancelled": { + "clearedOrders": [ + { + "eventTypeId": "1", + "eventId": "27312315", + "marketId": "1.117020524", + "selectionId": 9170340, + "handicap": 0.0, + "betId": "45496907354", + "placedDate": "2015-01-22T13:01:53.000Z", + "persistenceType": "LAPSE", + "orderType": "LIMIT", + "side": "BACK", + "priceRequested": 10.0, + "settledDate": "2015-01-22T13:05:01.000Z", + "betCount": 1, + "priceReduced": false, + "sizeCancelled": 5.0 + } + ], + "moreAvailable": false + } +} diff --git a/tests/samples/betfair_trading_stream.json b/tests/samples/betfair_trading_stream.json new file mode 100644 index 0000000..b586b95 --- /dev/null +++ b/tests/samples/betfair_trading_stream.json @@ -0,0 +1,463 @@ +{ + "subscribe_success": { + "op": "status", + "id": 2, + "statusCode": "SUCCESS", + "connectionClosed": false + }, + "market_snapshot": { + "clk": "AIElAJgiAIYjAMAhAOsm", + "oc": [ + { + "orc": [ + { + "uo": [ + { + "status": "E", + "rfs": "", + "sm": 0, + "pt": "L", + "sr": 2, + "rc": "REG_GGC", + "sv": 0, + "side": "B", + "p": 990, + "s": 2, + "pd": 1603894536000, + "sl": 0, + "sc": 0, + "ot": "L", + "rfo": "", + "id": "215144775671", + "rac": "" + } + ], + "id": 30246, + "fullImage": true + } + ], + "id": "1.174743281", + "fullImage": true + } + ], + "pt": 1603895058618, + "op": "ocm" + }, + "reconnect_image": { + "op": "ocm", + "id": 6, + "initialClk": "GpOH0JwBH762w50BHKKomJ0BGpzR5ZoBH5mWsJwB", + "clk": "AAAAAAAAAAAAAA==", + "conflateMs": 0, + "heartbeatMs": 5000, + "pt": 1468943673782, + "ct": "SUB_IMAGE", + "oc": [ + { + "id": "1.125657695", + "orc": [ + { + "fullImage": true, + "id": 48756, + "mb": [ + [ + 1.4, + 2 + ] + ] + } + ] + }, + { + "id": "1.125657760", + "orc": [ + { + "fullImage": true, + "id": 151478, + "uo": [ + { + "id": "71352090695", + "p": 12, + "s": 5, + "side": "B", + "status": "E", + "pt": "L", + "ot": "L", + "pd": 1468919099000, + "md": 1468933833000, + "avp": 12, + "sm": 4.75, + "sr": 0.25, + "sl": 0, + "sc": 0, + "sv": 0 + } + ], + "mb": [ + [ + 12, + 4.75 + ] + ] + } + ] + } + ] + }, + "reconnect_image_matched": { + "op": "ocm", + "id": 10, + "initialClk": "GtD10ZwBH5OJxZ0BHK75mZ0BGsKq6JoBH4THsZwB", + "clk": "AAAAAAAAAAAAAA==", + "conflateMs": 0, + "heartbeatMs": 5000, + "pt": 1468944647413, + "ct": "SUB_IMAGE", + "oc": [ + { + "id": "1.125670254", + "orc": [ + { + "fullImage": true, + "id": 5643663 + } + ] + }, + { + "id": "1.125657760", + "orc": [ + { + "fullImage": true, + "id": 151478, + "mb": [ + [ + 12, + 5 + ] + ] + } + ] + }, + { + "id": "1.125657695", + "orc": [ + { + "fullImage": true, + "id": 48756, + "mb": [ + [ + 1.4, + 2 + ] + ] + } + ] + } + ] + }, + "rule4_placed": { + "op": "ocm", + "id": 2, + "clk": "AK0CAPsBALEC", + "pt": 1467219304831, + "oc": [ + { + "id": "1.102151675", + "orc": [ + { + "fullImage": true, + "id": 6113662, + "uo": [ + { + "id": "10822867886", + "p": 12, + "s": 2, + "side": "B", + "status": "E", + "pt": "L", + "ot": "L", + "pd": 1467219304000, + "sm": 0, + "sr": 2, + "sl": 0, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC" + } + ] + } + ] + } + ] + }, + "rule4_matched": { + "op": "ocm", + "id": 2, + "clk": "AK0CAPsBALMC", + "pt": 1467219316709, + "oc": [ + { + "id": "1.102151675", + "orc": [ + { + "id": 6113662, + "uo": [ + { + "id": "10822867886", + "p": 12, + "s": 2, + "side": "B", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1467219304000, + "md": 1467219316000, + "avp": 12, + "sm": 2, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 0 + } + ], + "mb": [ + [ + 12, + 2 + ] + ] + } + ] + } + ] + }, + "rule4_reduced": { + "op": "ocm", + "id": 2, + "clk": "AK0CAJACALsC", + "pt": 1467219376611, + "oc": [ + { + "id": "1.102151675", + "orc": [ + { + "id": 6113662, + "uo": [ + { + "id": "10822867886", + "p": 12, + "s": 2, + "side": "B", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1467219304000, + "md": 1467219316000, + "avp": 9.47, + "sm": 2, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 0 + } + ], + "mb": [ + [ + 9.47, + 2 + ], + [ + 12, + 0 + ] + ] + } + ] + } + ] + }, + "var_lay_matched": { + "op": "ocm", + "clk": "AAAAbwBSAE0AAA==", + "status": 503, + "pt": 1740062495457, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 55271, + "uo": [ + { + "id": "309703551064", + "p": 2, + "s": 2, + "side": "L", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1740062487000, + "md": 1740062495000, + "avp": 2, + "sm": 2, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ], + "ml": [ + [ + 2, + 4 + ] + ], + "smc": { + "": { + "ml": [ + [ + 2, + 4 + ] + ] + } + } + } + ] + } + ] + }, + "var_back_placed": { + "op": "ocm", + "clk": "AAAAcgBSAE0AAA==", + "status": 503, + "pt": 1740062516601, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 44787, + "uo": [ + { + "id": "309703551066", + "p": 2, + "s": 2, + "side": "B", + "status": "E", + "pt": "L", + "ot": "L", + "pd": 1740062508000, + "sm": 0, + "sr": 2, + "sl": 0, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ] + } + ] + } + ] + }, + "var_lay_voided": { + "op": "ocm", + "pt": 1740062723421, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 55271, + "uo": [ + { + "id": "309703551064", + "p": 2, + "s": 2, + "side": "L", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1740062487000, + "md": 1740062495000, + "sm": 0, + "sr": 0, + "sl": 0, + "sc": 0, + "sv": 2, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ] + } + ] + } + ] + }, + "var_back_lapsed": { + "op": "ocm", + "pt": 1740062723421, + "oc": [ + { + "id": "1.183211193", + "orc": [ + { + "id": 44787, + "uo": [ + { + "id": "309703551066", + "p": 2, + "s": 2, + "side": "B", + "status": "EC", + "pt": "L", + "ot": "L", + "pd": 1740062508000, + "ld": 1740062723421, + "sm": 0, + "sr": 0, + "sl": 2, + "sc": 0, + "sv": 0, + "rac": "", + "rc": "REG_GGC", + "rfo": "", + "rfs": "" + } + ] + } + ] + } + ] + }, + "heartbeat_503": { + "op": "ocm", + "id": 3, + "clk": "AAAAAAAA", + "status": 503, + "pt": 1498137379766, + "ct": "HEARTBEAT" + }, + "subscription_example": { + "op": "orderSubscription", + "orderFilter": { + "includeOverallPosition": false, + "customerStrategyRefs": [ + "betstrategy1" + ], + "partitionMatchedByStrategyRef": true + }, + "segmentationEnabled": true + } +} diff --git a/tests/test_betfair.py b/tests/test_betfair.py index 85523f3..1fe5887 100644 --- a/tests/test_betfair.py +++ b/tests/test_betfair.py @@ -532,8 +532,10 @@ def test_no_trades_and_no_candles(self): adapter.fetch_ohlcv(ARSENAL) assert betting.requests == [] - def test_order_entry_is_not_offered(self): - with pytest.raises(NotSupported, match="no order entry"): + def test_order_entry_needs_the_account(self): + from synpath.trading.errors import CredentialsMissing + + with pytest.raises(CredentialsMissing, match="BETFAIR_APP_KEY"): synpath.Client({}).trading("betfair") def test_venue_rules_note_the_minimum_stake(self): diff --git a/tests/test_betfair_trading.py b/tests/test_betfair_trading.py new file mode 100644 index 0000000..fe65479 --- /dev/null +++ b/tests/test_betfair_trading.py @@ -0,0 +1,541 @@ +"""Betfair order entry: bets built from orders, Betfair's records read on the +YES leg, and the adapter against a scripted venue. + +Betting needs an account and a live application key, so nothing here is +recorded. The place, current-order and cleared-order records are the +examples in Betfair's Betting API documentation (placeOrders and Getting +Started), the JSON-RPC envelope taken off as the REST interface answers. +Betfair documents no example for the cancel, replace and update reports, +`getAccountFunds`, `getAccountDetails` or `listMarketProfitAndLoss`; those +are built field by field from its type definitions. Every request goes to +an `httpx.MockTransport`; nothing here reaches Betfair. +""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal +from urllib.parse import parse_qs + +import httpx +import pytest + +from synpath.betfair import BetfairSession, fee_schedule_of +from synpath.errors import BadRequest, RequestTimeout +from synpath.trading.betfair import ( + BetfairTrading, build_instruction, check_minimum, fill_of_current, odds_for, order_of_cleared, order_of_current, + order_of_report, positions_of_orders, settlement_of, stake_for, +) +from synpath.trading.credentials import BetfairCredentials +from synpath.trading.errors import ( + CredentialsMissing, InsufficientFunds, InvalidOrder, MarketHalted, OrderRejected, PermissionDenied, +) +from synpath.trading.types import EditRequest, OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce + +from conftest import load + +S = load("betfair_trading.json") +AUTH = load("betfair_auth.json") +LAY_RUNNER = "betfair:1.109850906-237486" +RUNNER = "betfair:1.117020524-9170340" +BET = "45496907354" +BETTING = "/exchange/betting/rest/v1.0" +ACCOUNT = "/exchange/account/rest/v1.0" + + +@pytest.fixture(autouse=True) +def no_ambient_credentials(monkeypatch, tmp_path): + for name in ("BETFAIR_APP_KEY", "BETFAIR_USERNAME", "BETFAIR_PASSWORD", "BETFAIR_CERT_PATH", + "BETFAIR_KEY_PATH", "BETFAIR_JURISDICTION"): + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + + +def request(**changes) -> OrderRequest: + base = {"market_id": LAY_RUNNER, "side": Side.SELL, "quantity": Decimal("6"), "price": Decimal("0.3333333333")} + return OrderRequest(**{**base, **changes}) + + +def current(**changes) -> dict: + return {**S["current_orders"]["currentOrders"][0], **changes} + + +class Venue: + """Answers Betting and Accounts API operations by name, and records them. + Logins go to the identity host on the same transport.""" + + def __init__(self, **answers): + self.answers = { + "getAccountDetails": S["account_details"], + "login": AUTH["login_success"], "certlogin": AUTH["certlogin_success"], + **answers, + } + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + name = req.url.path.rstrip("/").rsplit("/", 1)[-1] + answer = self.answers.get(name) + if answer is None: + return httpx.Response(404, json={"faultcode": "Client", "faultstring": "not found"}) + if callable(answer): + answer = answer(req) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + def sent(self, operation: str) -> list[dict]: + return [json.loads(r.content) for r in self.requests if r.url.path.rstrip("/").endswith(f"/{operation}")] + + def calls(self, operation: str) -> list[httpx.Request]: + return [r for r in self.requests if r.url.path.rstrip("/").endswith(f"/{operation}")] + + +class Catalog: + def fetch_fee_schedule(self, market_id): + return fee_schedule_of(market_id, 5) + + def close(self): + pass + + +def adapter(venue: Venue, *, session: BetfairSession | None = None, jurisdiction: str = "com", **kwargs) -> BetfairTrading: + transport = httpx.MockTransport(venue) + session = session or BetfairSession(app_key="APP-KEY-LIVE", session_token="SESSION-TOKEN-1", + jurisdiction=jurisdiction, client=httpx.Client(transport=transport)) + return BetfairTrading(session=session, catalog=Catalog(), + client=httpx.AsyncClient(transport=transport), **kwargs) + + +def run(coro): + return asyncio.run(coro) + + +def go(venue: Venue, call, **kwargs): + async def body(): + async with adapter(venue, **kwargs) as bf: + return await call(bf) + + return run(body()) + + +class TestInstructions: + def test_the_documented_lay_is_what_a_sell_builds(self): + # 6 contracts sold at 1/3 is a lay at odds 3 for a backer's stake of 2. + documented = S["place_lay_request"]["instructions"][0] + built = build_instruction(request()) + assert built["side"] == documented["side"] and built["orderType"] == documented["orderType"] + assert built["selectionId"] == int(documented["selectionId"]) and built["handicap"] == 0 + limit = built["limitOrder"] + assert (limit["size"], limit["price"], limit["persistenceType"]) == (2, 3, "LAPSE") + + def test_a_buy_backs_and_carries_the_client_order_id(self): + built = build_instruction(request(side=Side.BUY, price=Decimal("0.1"), quantity=Decimal("20"), + client_order_id="strat-1:42")) + assert built["side"] == "BACK" and built["limitOrder"]["size"] == 2 and built["limitOrder"]["price"] == 10 + assert built["customerOrderRef"] == "strat-1:42" + + def test_odds_snap_to_the_ladder_on_the_cheaper_side(self): + # 1/0.41 = 2.439: a buy pays no more (odds 2.44), a sell receives no less (odds 2.42). + assert odds_for(Side.BUY, Decimal("0.41")) == Decimal("2.44") + assert odds_for(Side.SELL, Decimal("0.41")) == Decimal("2.42") + # Above 100 the ladder steps by 10: 1/0.0075 = 133.3. + assert odds_for(Side.BUY, Decimal("0.0075")) == Decimal("140") + assert odds_for(Side.SELL, Decimal("0.0075")) == Decimal("130") + + def test_prices_off_the_ladder_are_refused(self): + with pytest.raises(InvalidOrder): + odds_for(Side.BUY, Decimal("0.0005")) + with pytest.raises(InvalidOrder): + odds_for(Side.SELL, Decimal("0.995")) + + def test_stakes_round_down_to_the_penny(self): + assert stake_for(Decimal("10"), Decimal("3")) == Decimal("3.33") + with pytest.raises(InvalidOrder, match="penny"): + stake_for(Decimal("0.01"), Decimal("3")) + + def test_persistence_and_fill_or_kill(self): + assert build_instruction(request(params={"persistence": "PERSIST"}))["limitOrder"]["persistenceType"] == "PERSIST" + assert build_instruction(request(params={"keep_in_play": True}))["limitOrder"]["persistenceType"] == "PERSIST" + assert build_instruction(request(params={"persistence": "market_on_close"}))["limitOrder"]["persistenceType"] \ + == "MARKET_ON_CLOSE" + fok = build_instruction(request(side=Side.BUY, price=Decimal("1") / Decimal("21"), quantity=Decimal("105"), + time_in_force=TimeInForce.FOK, params={"min_fill": "63"}))["limitOrder"] + # As the documented fill or kill: 5 at 21, at least 3 of it. + assert fok == {"size": 5, "price": 21, "timeInForce": "FILL_OR_KILL", "minFillSize": 3} + with pytest.raises(InvalidOrder, match="persist"): + build_instruction(request(type=OrderType.MARKET, params={"persistence": "PERSIST"})) + with pytest.raises(InvalidOrder): + build_instruction(request(params={"persistence": "KEEP"})) + + @pytest.mark.parametrize("changes", [ + {"time_in_force": TimeInForce.GTD, "expires_at": 1}, {"post_only": True}, {"reduce_only": True}, + {"type": OrderType.ICEBERG}, {"type": OrderType.MARKET, "price": None}, + {"market_id": "betfair:1.109850906"}, {"client_order_id": "x" * 33}, + ]) + def test_what_betfair_does_not_hold_is_refused(self, changes): + with pytest.raises(BadRequest): # InvalidOrder is a BadRequest + build_instruction(request(**changes)) + + def test_the_minimum_stake_or_the_minimum_payout(self): + check_minimum(Decimal("1"), Decimal("2"), currency="GBP") + # 10p at 100 pays £10: valid by the payout rule, except on .it and .es. + check_minimum(Decimal("0.10"), Decimal("100"), currency="GBP") + with pytest.raises(InvalidOrder, match="payout"): + check_minimum(Decimal("0.10"), Decimal("50"), currency="GBP") + with pytest.raises(InvalidOrder): + check_minimum(Decimal("0.10"), Decimal("100"), currency="EUR", jurisdiction="it") + check_minimum(Decimal("0.10"), Decimal("2"), currency="XYZ") # left to Betfair + + +class TestRecords: + def test_the_documented_placement_rests(self): + order = order_of_report(S["place_lay"]["instructionReports"][0], market_id="1.109850906") + assert order.id == "31242604945" and order.market_id == LAY_RUNNER and order.side == Side.SELL + assert order.status == OrderStatus.OPEN and order.quantity == Decimal("6") + assert order.price == Decimal("0.3333333333") and order.filled == 0 and order.remaining == Decimal("6") + assert order.created_at == 1383142967000 + + def test_an_order_status_on_the_report_wins(self): + order = order_of_report(S["place_persist"]["instructionReports"][0], market_id="1.240636817") + assert order.status == OrderStatus.OPEN and order.side == Side.BUY and order.quantity == Decimal("20") + assert order.info["instruction"]["limitOrder"]["persistenceType"] == "PERSIST" + + def test_fill_or_kill_killed_and_partly_filled(self): + killed = order_of_report(S["place_fok_killed"]["instructionReports"][0], market_id="1.126124422") + assert killed.status == OrderStatus.EXPIRED and killed.filled == 0 and killed.remaining == 0 + assert killed.time_in_force == TimeInForce.FOK + partial = order_of_report(S["place_fok_partial"]["instructionReports"][0], market_id="1.126124422") + assert partial.status == OrderStatus.EXPIRED and partial.quantity == Decimal("105.0") + assert partial.filled == Decimal("3.4400000000000004") * 21 + assert partial.average_price == (1 / Decimal("21.32267441860465")).quantize(Decimal("1e-10")) + + def test_a_current_order(self): + order = order_of_current(S["current_orders"]["currentOrders"][0]) + assert order.id == BET and order.market_id == RUNNER and order.status == OrderStatus.OPEN + assert order.quantity == Decimal("50.0") and order.remaining == Decimal("50.0") and order.price == Decimal("0.1") + + def test_finished_orders_are_closed_or_canceled(self): + matched = order_of_current(current(status="EXECUTION_COMPLETE", sizeMatched=5.0, sizeRemaining=0.0, + averagePriceMatched=10.5)) + assert matched.status == OrderStatus.CLOSED and matched.filled == Decimal("50.0") and matched.remaining == 0 + assert matched.average_price == (1 / Decimal("10.5")).quantize(Decimal("1e-10")) + cancelled = order_of_current(current(status="EXECUTION_COMPLETE", sizeMatched=2.0, sizeRemaining=0.0, + sizeCancelled=3.0, averagePriceMatched=10.0)) + assert cancelled.status == OrderStatus.CANCELED and cancelled.filled == Decimal("20.0") + + def test_a_cancelled_bet_from_the_cleared_orders(self): + order = order_of_cleared({**S["cleared_cancelled"]["clearedOrders"][0], "betStatus": "CANCELLED"}) + assert order.status == OrderStatus.CANCELED and order.quantity == Decimal("50.0") and order.filled == 0 + + def test_a_fill_is_the_bet_at_its_average_matched_odds(self): + fill = fill_of_current(current(status="EXECUTION_COMPLETE", sizeMatched=5.0, averagePriceMatched=10.5, + matchedDate="2015-01-22T13:02:00.000Z")) + assert fill.id == fill.order_id == BET and fill.quantity == Decimal("52.50") + assert fill.price == (1 / Decimal("10.5")).quantize(Decimal("1e-10")) and fill.timestamp == 1421931720000 + assert fill_of_current(current()) is None + + def test_positions_net_backs_and_lays_per_runner(self): + rows = [current(sizeMatched=2.0, averagePriceMatched=12.0), + current(betId="2", side="LAY", sizeMatched=1.0, averagePriceMatched=10.0)] + [position] = positions_of_orders(rows) + assert position.market_id == RUNNER and position.side == PositionSide.LONG + assert (position.inventory_yes, position.inventory_no, position.contracts) == (24, 10, 14) + assert position.margin == Decimal("2.0") + Decimal("9.0") + assert position.entry_price == (Decimal("3") / Decimal("34")).quantize(Decimal("1e-10")) + + def test_settlements_read_the_bets_outcome_as_the_runners(self): + rows = S["cleared_orders"]["clearedOrders"] + won = settlement_of(rows[1]) + assert won.market_id == "betfair:1.144157120-2830908" and won.held == PositionSide.LONG + assert won.result == "yes" and won.won is True and won.quantity == Decimal("2.56") and won.cost == 2 + assert won.pnl == Decimal("0.56") and won.timestamp is not None + lost = settlement_of(rows[3]) + assert lost.result == "no" and lost.won is False and lost.pnl == Decimal("-2.65") + lay = settlement_of({**rows[3], "side": "LAY", "betOutcome": "WON"}) + assert lay.held == PositionSide.SHORT and lay.result == "no" and lay.cost is None + + +class TestPlacing: + def test_a_bet_is_placed_with_the_session_and_its_client_id(self): + venue = Venue(placeOrders=S["place_lay"]) + order = go(venue, lambda bf: bf.create_order(request(client_order_id="c-1"))) + [place] = venue.calls("placeOrders") + assert place.url.path == f"{BETTING}/placeOrders/" + assert place.headers["X-Application"] == "APP-KEY-LIVE" and place.headers["X-Authentication"] == "SESSION-TOKEN-1" + assert venue.calls("getAccountDetails")[0].url.path == f"{ACCOUNT}/getAccountDetails/" + [body] = venue.sent("placeOrders") + assert body["marketId"] == "1.109850906" and body["customerRef"] == "c-1" + assert body["instructions"][0]["customerOrderRef"] == "c-1" + assert order.status == OrderStatus.OPEN and order.client_order_id == "c-1" and order.id == "31242604945" + + def test_a_bet_below_the_minimum_is_refused_before_sending(self): + venue = Venue(placeOrders=S["place_lay"]) + with pytest.raises(InvalidOrder, match="minimum"): + go(venue, lambda bf: bf.create_order(request(quantity=Decimal("1.5")))) + assert venue.calls("placeOrders") == [] + + def test_a_market_order_cancels_its_unmatched_rest(self): + cancelled = current(betId="31242604945", marketId="1.109850906", selectionId=237486, side="LAY", + priceSize={"price": 3.0, "size": 2.0}, status="EXECUTION_COMPLETE", + sizeRemaining=0.0, sizeCancelled=2.0) + venue = Venue(placeOrders=S["place_lay"], cancelOrders={**S["cancel"], "marketId": "1.109850906"}, + listCurrentOrders={"currentOrders": [cancelled], "moreAvailable": False}) + order = go(venue, lambda bf: bf.create_order(request(type=OrderType.MARKET))) + [cancel] = venue.sent("cancelOrders") + assert cancel == {"marketId": "1.109850906", "instructions": [{"betId": "31242604945"}]} + assert order.status == OrderStatus.CANCELED and order.type == OrderType.MARKET + + def test_a_fill_or_kill_is_left_to_betfair(self): + venue = Venue(placeOrders=S["place_fok_killed"]) + order = go(venue, lambda bf: bf.create_order(request( + market_id="betfair:1.126124422-10590221", side=Side.BUY, price=Decimal(1) / 21, quantity=Decimal("105"), + time_in_force=TimeInForce.FOK))) + assert order.status == OrderStatus.EXPIRED and venue.calls("cancelOrders") == [] + + def test_a_timeout_is_looked_up_by_the_client_order_id(self): + timeout = {"status": "TIMEOUT", "marketId": "1.109850906", "instructionReports": [{"status": "TIMEOUT"}]} + found = current(betId="31242604945", marketId="1.109850906", selectionId=237486, customerOrderRef="c-9") + venue = Venue(placeOrders=timeout, listCurrentOrders={"currentOrders": [found], "moreAvailable": False}) + order = go(venue, lambda bf: bf.create_order(request(client_order_id="c-9"))) + assert order.id == "31242604945" and venue.sent("listCurrentOrders")[0]["customerOrderRefs"] == ["c-9"] + with pytest.raises(RequestTimeout): + go(Venue(placeOrders=timeout), lambda bf: bf.create_order(request())) + + def test_one_request_per_betfair_market(self): + def place(req): + body = json.loads(req.content) + report = S["place_lay"]["instructionReports"][0] + return {"status": "SUCCESS", "marketId": body["marketId"], + "instructionReports": [report for _ in body["instructions"]]} + + venue = Venue(placeOrders=place) + results = go(venue, lambda bf: bf.create_orders([ + request(), request(market_id="betfair:1.2-47972"), request(), request(post_only=True)])) + assert [type(r).__name__ for r in results] == ["Order", "Order", "Order", "InvalidOrder"] + bodies = venue.sent("placeOrders") + assert sorted((b["marketId"], len(b["instructions"])) for b in bodies) == [("1.109850906", 2), ("1.2", 1)] + assert all("customerRef" not in b for b in bodies) + assert results[1].market_id == "betfair:1.2-237486" # the report names the runner placed + + def test_the_italian_exchange_takes_fifty_a_request(self): + def place(req): + body = json.loads(req.content) + report = S["place_lay"]["instructionReports"][0] + return {"status": "SUCCESS", "instructionReports": [report for _ in body["instructions"]]} + + venue = Venue(placeOrders=place, getAccountDetails={"currencyCode": "EUR"}) + go(venue, lambda bf: bf.create_orders([request(quantity=Decimal("6")) for _ in range(60)]), jurisdiction="it") + assert [len(b["instructions"]) for b in venue.sent("placeOrders")] == [50, 10] + + def test_a_rejected_bet_is_typed(self): + venue = Venue(placeOrders=S["place_version_rejected"]) + with pytest.raises(OrderRejected) as caught: + go(venue, lambda bf: bf.create_order(request(params={"market_version": 4249587130}))) + assert caught.value.reason == "bet_taken_or_lapsed" + assert venue.sent("placeOrders")[0]["marketVersion"] == {"version": 4249587130} + + @pytest.mark.parametrize("code,error", [ + ("PERMISSION_DENIED", PermissionDenied), ("INSUFFICIENT_FUNDS", InsufficientFunds), + ("MARKET_SUSPENDED", MarketHalted), + ]) + def test_a_refused_request_is_typed(self, code, error): + refused = {"status": "FAILURE", "errorCode": code, "marketId": "1.109850906", + "instructionReports": [{"status": "FAILURE", "errorCode": "ERROR_IN_ORDER", + "instruction": S["place_lay"]["instructionReports"][0]["instruction"]}]} + with pytest.raises(error): + go(Venue(placeOrders=refused), lambda bf: bf.create_order(request())) + + def test_an_expired_session_logs_in_again_once(self): + answers = iter([httpx.Response(400, json=AUTH["invalid_session"]), httpx.Response(200, json=S["account_funds"])]) + venue = Venue(getAccountFunds=lambda req: next(answers)) + transport = httpx.MockTransport(venue) + session = BetfairSession(app_key="APP-KEY-LIVE", username="jbloggs", password="pw", + client=httpx.Client(transport=transport)) + balance = go(venue, lambda bf: bf.fetch_balance(), session=session) + assert len(venue.calls("login")) == 2 and balance.available == Decimal("95.5") + + def test_certificate_login_from_the_credentials(self, tmp_path): + venue = Venue(getAccountFunds=S["account_funds"]) + creds = BetfairCredentials(app_key="APP-KEY-LIVE", username="jbloggs", password="pw", + cert_path=str(tmp_path / "c.crt"), key_path=str(tmp_path / "c.key")) + transport = httpx.MockTransport(venue) + + async def body(): + async with BetfairTrading(creds, catalog=Catalog(), client=httpx.AsyncClient(transport=transport), + identity_client=httpx.Client(transport=transport)) as bf: + return await bf.fetch_balance() + + run(body()) + [login] = venue.calls("certlogin") + assert login.url.host == "identitysso-cert.betfair.com" + assert parse_qs(login.content.decode()) == {"username": ["jbloggs"], "password": ["pw"]} + assert venue.calls("getAccountFunds")[0].headers["X-Authentication"] == AUTH["certlogin_success"]["sessionToken"] + + +class TestManaging: + def test_cancel_finds_the_market_and_reads_the_bet_back(self): + after = current(status="EXECUTION_COMPLETE", sizeRemaining=0.0, sizeCancelled=5.0) + reads = iter([{"currentOrders": [current()], "moreAvailable": False}, + {"currentOrders": [after], "moreAvailable": False}]) + venue = Venue(cancelOrders=S["cancel"], listCurrentOrders=lambda req: next(reads)) + order = go(venue, lambda bf: bf.cancel_order(BET)) + assert venue.sent("cancelOrders") == [{"marketId": "1.117020524", "instructions": [{"betId": BET}]}] + assert order.status == OrderStatus.CANCELED + + def test_a_bet_already_taken_is_read_back(self): + matched = current(status="EXECUTION_COMPLETE", sizeMatched=5.0, sizeRemaining=0.0, averagePriceMatched=10.0) + venue = Venue(cancelOrders=S["cancel_taken"], listCurrentOrders={"currentOrders": [matched], "moreAvailable": False}) + order = go(venue, lambda bf: bf.cancel_order(BET, market_id=RUNNER)) + assert order.status == OrderStatus.CLOSED + + def test_cancel_all_everywhere_on_a_market_or_on_a_runner(self): + venue = Venue(cancelOrders=S["cancel"], listCurrentOrders={"currentOrders": [current()], "moreAvailable": False}) + + async def calls(bf): + return (await bf.cancel_all_orders(), await bf.cancel_all_orders(market_id="betfair:1.117020524"), + await bf.cancel_all_orders(market_id=RUNNER)) + + assert go(venue, calls) == (1, 1, 1) + everything, market, runner = venue.sent("cancelOrders") + assert everything == {} and market == {"marketId": "1.117020524"} + assert runner == {"marketId": "1.117020524", "instructions": [{"betId": BET}]} + + def test_a_new_price_is_a_replaced_bet(self): + venue = Venue(replaceOrders=S["replace"]) + order = go(venue, lambda bf: bf.edit_order(EditRequest(order_id=BET, price=Decimal("1") / 12), + current=order_of_current(current()))) + assert venue.sent("replaceOrders") == [{"marketId": "1.117020524", + "instructions": [{"betId": BET, "newPrice": 12}]}] + assert order.id == "45496907999" and order.price == (1 / Decimal(12)).quantize(Decimal("1e-10")) + assert order.queue_priority_preserved is False and order.info["replaced"] == BET + + def test_a_smaller_quantity_is_a_partial_cancel(self): + after = current(sizeRemaining=3.0, sizeCancelled=2.0) + venue = Venue(cancelOrders=S["cancel_partial"], listCurrentOrders={"currentOrders": [after], "moreAvailable": False}) + order = go(venue, lambda bf: bf.edit_order(EditRequest(order_id=BET, quantity=Decimal("30")), + current=order_of_current(current()))) + assert venue.sent("cancelOrders") == [{"marketId": "1.117020524", + "instructions": [{"betId": BET, "sizeReduction": 2}]}] + assert order.remaining == Decimal("30.0") and order.queue_priority_preserved is True + + def test_a_stake_cannot_be_raised(self): + with pytest.raises(InvalidOrder, match="raise"): + go(Venue(), lambda bf: bf.edit_order(EditRequest(order_id=BET, quantity=Decimal("80")), + current=order_of_current(current()))) + + def test_persistence_is_updated_in_place(self): + kept = current(persistenceType="PERSIST") + venue = Venue(updateOrders=S["update"], listCurrentOrders={"currentOrders": [kept], "moreAvailable": False}) + order = go(venue, lambda bf: bf.update_persistence(BET, "persist", market_id=RUNNER)) + assert venue.sent("updateOrders") == [{"marketId": "1.117020524", + "instructions": [{"betId": BET, "newPersistenceType": "PERSIST"}]}] + assert order.info["persistenceType"] == "PERSIST" + + def test_a_bet_gone_from_the_current_orders_is_found_among_the_cleared(self): + def cleared(req): + status = json.loads(req.content)["betStatus"] + return S["cleared_cancelled"] if status == "CANCELLED" else {"clearedOrders": [], "moreAvailable": False} + + venue = Venue(listCurrentOrders={"currentOrders": [], "moreAvailable": False}, listClearedOrders=cleared) + order = go(venue, lambda bf: bf.fetch_order(BET)) + assert order.status == OrderStatus.CANCELED and order.market_id == RUNNER + assert [b["betStatus"] for b in venue.sent("listClearedOrders")] == ["CANCELLED"] + + def test_open_orders_on_a_runner_page_through(self): + pages = iter([{"currentOrders": [current()], "moreAvailable": True}, + {"currentOrders": [current(betId="2", selectionId=1)], "moreAvailable": False}]) + venue = Venue(listCurrentOrders=lambda req: next(pages)) + orders = go(venue, lambda bf: bf.fetch_open_orders(market_id=RUNNER)) + assert [o.id for o in orders] == [BET] + first, second = venue.sent("listCurrentOrders") + assert first["orderProjection"] == "EXECUTABLE" and first["marketIds"] == ["1.117020524"] + assert second["fromRecord"] == 1 + + def test_fetch_orders_pages_by_offset(self): + venue = Venue(listCurrentOrders={"currentOrders": [current()], "moreAvailable": True}) + page = go(venue, lambda bf: bf.fetch_orders(status="open", limit=1, since=1421931713000)) + assert page.next_cursor == "1" and len(page) == 1 + [sent] = venue.sent("listCurrentOrders") + assert sent["recordCount"] == 1 and sent["dateRange"] == {"from": "2015-01-22T13:01:53Z"} + + def test_my_trades_are_the_bets_that_stopped_matching(self): + rows = [current(status="EXECUTION_COMPLETE", sizeMatched=5.0, sizeRemaining=0.0, averagePriceMatched=10.0, + matchedDate="2015-01-22T13:02:00.000Z"), + current(betId="2", sizeMatched=1.0, averagePriceMatched=10.0)] + venue = Venue(listCurrentOrders={"currentOrders": rows, "moreAvailable": False}) + fills = go(venue, lambda bf: bf.fetch_my_trades()) + assert [f.id for f in fills] == [BET] and fills[0].quantity == Decimal("50.0") + assert venue.sent("listCurrentOrders")[0]["orderBy"] == "BY_MATCH_TIME" + + def test_balance_positions_profit_and_loss_and_settlements(self): + venue = Venue( + getAccountFunds=S["account_funds"], listMarketProfitAndLoss=S["profit_and_loss"], + listCurrentOrders={"currentOrders": [current(sizeMatched=2.0, averagePriceMatched=12.0)], "moreAvailable": False}, + listClearedOrders=S["cleared_orders"], + ) + + async def calls(bf): + return (await bf.fetch_balance(), await bf.fetch_positions(market_id=RUNNER), + await bf.fetch_profit_and_loss([RUNNER]), await bf.fetch_settlements()) + + balance, positions, pnl, settlements = go(venue, calls) + assert (balance.total, balance.available, balance.locked, balance.currency) == ( + Decimal("100.0"), Decimal("95.5"), Decimal("4.5"), "GBP") + assert positions[0].contracts == Decimal("24.0") + assert pnl[0]["profitAndLosses"][0]["ifWin"] == 45.0 + assert venue.sent("listMarketProfitAndLoss")[0]["marketIds"] == ["1.117020524"] + # The documented report mixes a market roll-up in; only bets naming a runner settle one. + assert len(settlements) == 6 and venue.sent("listClearedOrders")[0]["betStatus"] == "SETTLED" + + def test_the_fee_is_commission_on_what_the_bet_wins(self): + async def calls(bf): + return (await bf.fetch_fee_estimate(RUNNER, Side.BUY, Decimal("0.4"), Decimal("100")), + await bf.fetch_fee_estimate(RUNNER, Side.SELL, Decimal("0.4"), Decimal("100"))) + + buy, sell = go(Venue(), calls) + assert buy.taker_fee == Decimal("3.0") and sell.taker_fee == Decimal("2.0") and buy.maker_fee == buy.taker_fee + assert buy.currency == "GBP" + + +class TestWiring: + def test_credentials_are_needed(self): + with pytest.raises(CredentialsMissing): + BetfairTrading(None) + + def test_the_client_the_engine_and_serve_build_it(self): + import synpath + from synpath.engine.__main__ import ADAPTERS + from synpath.server.serve import VENUES + + creds = BetfairCredentials(app_key="k", username="u", password="p") + trading = synpath.Client({"betfair": creds}).trading("betfair") + try: + assert isinstance(trading, BetfairTrading) and trading.has["edit_order"] is True + finally: + run(trading.close()) + assert ADAPTERS["betfair"] == ("..trading.betfair", "BetfairTrading") and "betfair" in VENUES + assert synpath.BetfairTrading is BetfairTrading + + def test_the_engine_streams_share_the_adapters_session(self): + from synpath.engine.feeds import _trading_streams + from synpath.ws.betfair import BetfairMarketStream, BetfairUserStream + + creds = BetfairCredentials(app_key="k", username="u", password="p") + trading = BetfairTrading(creds, catalog=Catalog()) + try: + streams = _trading_streams({"betfair": trading}, {"betfair": creds})["betfair"] + assert isinstance(streams.market, BetfairMarketStream) and isinstance(streams.private, BetfairUserStream) + assert streams.private.session is trading.session is streams.market.session + finally: + run(trading.close()) + + def test_doctor_names_the_login(self, tmp_path, capsys): + from synpath.trading.__main__ import doctor + + env = tmp_path / ".env" + env.write_text("BETFAIR_APP_KEY=app-key-secret-1\nBETFAIR_USERNAME=jbloggs\nBETFAIR_PASSWORD=secret-pw-123\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + assert "user jbloggs" in out and "live key" in out + assert "secret-pw-123" not in out and "app-key-secret-1" not in out diff --git a/tests/test_betfair_ws.py b/tests/test_betfair_ws.py index 4cf7b67..d331a56 100644 --- a/tests/test_betfair_ws.py +++ b/tests/test_betfair_ws.py @@ -1,8 +1,11 @@ """Betfair's Exchange Stream API, spoken against a fake TLS socket. -The messages are hand-built to the shapes in Betfair's Exchange Stream API -documentation (and betfairlightweight's streaming fixtures); nothing here -opens a connection to Betfair. +The market messages are hand-built to the shapes in Betfair's Exchange +Stream API documentation (and betfairlightweight's streaming fixtures). The +order change messages are the documentation's own examples (a market-level +snapshot, a reconnection image, a runner removal, a VAR void), the VAR +example's bare runner changes wrapped in an `ocm`. Nothing here opens a +connection to Betfair. """ from __future__ import annotations @@ -13,9 +16,12 @@ import pytest -from synpath import AuthenticationError, BetfairMarketStream +from synpath import AuthenticationError, BetfairMarketStream, BetfairUserStream from synpath.betfair import BetfairSession -from synpath.ws.base import BookEvent, MarketStatusEvent, QuoteEvent, StreamStatusEvent +from synpath.trading.types import OrderStatus, PositionSide, Side +from synpath.ws.base import ( + BookEvent, FillEvent, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, StreamStatusEvent, +) from synpath.ws.betfair import ( DELAYED_CONFLATE_MS, MAX_MARKETS, LineSocket, apply_ladder, changed, yes_levels, yes_price, ) @@ -355,11 +361,145 @@ async def test_betfairs_own_refusal_is_reported(self): class TestFeeds: - def test_the_engine_gets_a_market_stream_and_no_account_stream(self): + def test_the_engine_gets_a_market_stream_and_an_account_stream(self): from synpath.engine.feeds import default_streams from synpath.trading.credentials import BetfairCredentials creds = BetfairCredentials(app_key="k", username="u", password="p") streams = default_streams({"betfair": object()}, {"betfair": creds}) - assert isinstance(streams["betfair"].market, BetfairMarketStream) and streams["betfair"].private is None + assert isinstance(streams["betfair"].market, BetfairMarketStream) + assert isinstance(streams["betfair"].private, BetfairUserStream) assert default_streams({"betfair": object()}, {}) == {} + + +ORDERS = load("betfair_trading_stream.json") +REMOVAL_RUNNER = "betfair:1.102151675-6113662" + + +def order_message(name: str, **changes) -> str: + return json.dumps({**ORDERS[name], **changes}) + + +async def order_stream(*kinds, **kwargs) -> BetfairUserStream: + stream = BetfairUserStream(session=a_session(), **kwargs) + for kind in kinds or ("orders", "fills", "positions"): + await getattr(stream, {"orders": "watch_orders", "fills": "watch_my_trades", + "positions": "watch_positions"}[kind])() + await authenticated(stream) + stream._dispatch(order_message("subscribe_success", id=stream._sub_id)) + drain(stream) + return stream + + +class TestOrderStream: + async def test_the_order_subscription_follows_authentication(self): + stream = await order_stream(strategy_refs=["betstrategy1"]) + subscription = stream._ws.sent[-1] + documented = ORDERS["subscription_example"] + assert subscription["op"] == documented["op"] and subscription["segmentationEnabled"] is True + assert subscription["orderFilter"] == {"includeOverallPosition": True, "customerStrategyRefs": ["betstrategy1"]} + assert "clk" not in subscription + + async def test_nothing_is_subscribed_until_something_is_watched(self): + stream = BetfairUserStream(session=a_session()) + await authenticated(stream) + assert [frame["op"] for frame in stream._ws.sent] == ["authentication"] + + async def test_a_snapshot_reports_the_resting_bet_without_a_fill(self): + stream = await order_stream() + stream._dispatch(order_message("market_snapshot")) + events = drain(stream) + [event] = [e for e in events if isinstance(e, OrderEvent)] + order = event.order + assert order.id == "215144775671" and order.market_id == "betfair:1.174743281-30246" + assert order.side == Side.BUY and order.status == OrderStatus.OPEN and order.price == yes_price(990) + assert order.quantity == Decimal("1980") and order.created_at == 1603894536000 + assert not [e for e in events if isinstance(e, FillEvent)] + + async def test_a_bet_matching_is_a_fill_then_a_runner_removal_is_not(self): + stream = await order_stream() + stream._dispatch(order_message("rule4_placed")) + placed = drain(stream) + assert [type(e).__name__ for e in placed] == ["OrderEvent", "PositionEvent"] + assert placed[0].order.status == OrderStatus.OPEN and placed[1].position.contracts == 0 + + stream._dispatch(order_message("rule4_matched")) + fill, order, position = drain(stream) + assert isinstance(fill, FillEvent) and fill.fill.id == "10822867886:2" + assert fill.fill.quantity == Decimal("24") and fill.fill.price == yes_price(12) + assert order.order.status == OrderStatus.CLOSED and order.order.filled == Decimal("24") + assert order.order.last_fill_quantity == Decimal("24") + assert position.position.market_id == REMOVAL_RUNNER and position.position.inventory_yes == Decimal("24") + assert position.position.side == PositionSide.LONG + + # A runner removed: the matched price is reduced to 9.47, nothing new matched. + stream._dispatch(order_message("rule4_reduced")) + events = drain(stream) + assert not [e for e in events if isinstance(e, FillEvent)] + [position] = [e.position for e in events if isinstance(e, PositionEvent)] + assert position.inventory_yes == Decimal("2") * Decimal("9.47") + assert position.info["matched_backs"] == [["9.47", "2"]] + + async def test_a_reconnection_image_is_the_baseline(self): + stream = await order_stream() + stream._dispatch(order_message("reconnect_image")) + events = drain(stream) + assert not [e for e in events if isinstance(e, FillEvent)] + [order] = [e.order for e in events if isinstance(e, OrderEvent)] + assert order.filled == Decimal("4.75") * 12 and order.remaining == Decimal("0.25") * 12 + positions = {e.position.market_id: e.position for e in events if isinstance(e, PositionEvent)} + assert positions["betfair:1.125657695-48756"].inventory_yes == Decimal("2.8") + assert stream.initial_clk == ORDERS["reconnect_image"]["initialClk"] + + # The rest matched while disconnected: the next image has the bet gone and the position whole. + stream._dispatch(order_message("reconnect_image_matched")) + events = drain(stream) + positions = {e.position.market_id: e.position for e in events if isinstance(e, PositionEvent)} + assert positions["betfair:1.125657760-151478"].inventory_yes == Decimal("60") + assert positions["betfair:1.125670254-5643663"].contracts == 0 + assert stream.orders == {} + + async def test_a_reconnect_resumes_from_the_clock(self): + stream = await order_stream() + stream._dispatch(order_message("reconnect_image")) + await authenticated(stream) + resubscribe = stream._ws.sent[-1] + assert resubscribe["initialClk"] == ORDERS["reconnect_image"]["initialClk"] + assert resubscribe["clk"] == ORDERS["reconnect_image"]["clk"] + + async def test_a_var_void_and_a_lapse_end_bets_without_fills(self): + stream = await order_stream() + stream._dispatch(order_message("var_lay_matched")) + events = drain(stream) + status = [e for e in events if isinstance(e, StreamStatusEvent)] + assert status[0].state == "error" and status[0].reconcile_required and "503" in status[0].detail + [fill] = [e.fill for e in events if isinstance(e, FillEvent)] + assert fill.side == Side.SELL and fill.quantity == Decimal("4") and fill.price == Decimal("0.5") + [position] = [e.position for e in events if isinstance(e, PositionEvent)] + assert position.side == PositionSide.SHORT and position.inventory_no == Decimal("8") + + stream._dispatch(order_message("var_back_placed")) + drain(stream) + stream._dispatch(order_message("var_lay_voided")) + stream._dispatch(order_message("var_back_lapsed")) + events = drain(stream) + assert not [e for e in events if isinstance(e, FillEvent)] + assert [e.state for e in events if isinstance(e, StreamStatusEvent)] == ["resynced"] + voided, lapsed = [e.order for e in events if isinstance(e, OrderEvent)] + assert voided.status == OrderStatus.CANCELED and voided.filled == 0 and voided.info["sizeVoided"] == 2 + assert lapsed.status == OrderStatus.CANCELED and lapsed.info["sizeLapsed"] == 2 and lapsed.remaining == 0 + + async def test_only_the_markets_watched_are_passed_on(self): + stream = BetfairUserStream(session=a_session()) + await stream.watch_orders(["betfair:1.102151675"]) + await authenticated(stream) + stream._dispatch(order_message("market_snapshot")) + stream._dispatch(order_message("rule4_placed")) + orders = [e.order for e in drain(stream) if isinstance(e, OrderEvent)] + assert [o.market_id for o in orders] == [REMOVAL_RUNNER] + + async def test_a_heartbeat_carries_the_streams_health(self): + stream = await order_stream() + stream._dispatch(order_message("heartbeat_503")) + [status] = drain(stream) + assert status.state == "error" and stream.clk == "AAAAAAAA" From 5a78ca85e6a21626bfbb0a62f59a0e9fc6299a71 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 2/8] feat: smarkets-trading Co-Authored-By: Claude Opus 5.5 --- .env.example | 11 +- README.md | 4 +- docs/api.md | 2 + docs/capabilities.md | 33 +- docs/streaming.md | 8 +- docs/trading.md | 88 ++- synpath/__init__.py | 4 +- synpath/client.py | 6 + synpath/engine/__main__.py | 1 + synpath/engine/feeds.py | 10 +- synpath/server/serve.py | 2 +- synpath/smarkets.py | 4 +- synpath/trading/__init__.py | 5 +- synpath/trading/__main__.py | 8 +- synpath/trading/credentials.py | 14 +- synpath/trading/init.py | 8 +- synpath/trading/instruments.py | 10 +- synpath/trading/smarkets.py | 921 ++++++++++++++++++++++++++++ tests/samples/smarkets_trading.json | 235 +++++++ tests/test_smarkets.py | 6 +- tests/test_smarkets_trading.py | 528 ++++++++++++++++ 21 files changed, 1848 insertions(+), 60 deletions(-) create mode 100644 synpath/trading/smarkets.py create mode 100644 tests/samples/smarkets_trading.json create mode 100644 tests/test_smarkets_trading.py diff --git a/.env.example b/.env.example index 207b78e..e0737a3 100644 --- a/.env.example +++ b/.env.example @@ -106,11 +106,12 @@ BETFAIR_CERT_PATH= BETFAIR_KEY_PATH= BETFAIR_JURISDICTION= -# Smarkets (market data and a polled stream; no order entry yet): market data -# needs no login at all. A login (optional) sends a session token for -# undelayed quotes and your account's own rate limits. Betting through the API -# needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API terms -# allow viewing prices and betting on your own account; odds scraping is +# Smarkets (market data, a polled stream, and order entry): market data needs +# no login at all. A login sends a session token for undelayed quotes, your +# account's own rate limits, and its orders, balance and positions; an account +# with two-factor login cannot log in from here. Placing orders through the +# API needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API +# terms allow viewing prices and betting on your own account; odds scraping is # prohibited. SMARKETS_USERNAME= SMARKETS_PASSWORD= diff --git a/README.md b/README.md index 40c5d11..d86b91f 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [SX Bet](https://sx.bet) | `sx_bet` | ✓ sports | ✓ | WebSocket | one book per market, both sides read it | | | [Gemini Predictions](https://www.gemini.com/predictions) | `gemini` | ✓ no account, sports taxonomy | ✓ US accounts with Prediction Markets access | WebSocket (no key for books) | one book per contract, in YES space | | | [Betfair Exchange](https://www.betfair.com/exchange) | `betfair` | ✓ your own account and app key (delayed key: 1-180 s late, 3 levels) | — | Exchange Stream API (TLS socket) | one book per runner, back and lay read as YES | -| | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | — | polled from REST quotes | one book per contract, bids are YES bids | +| | [Smarkets](https://smarkets.com) | `smarkets` | ✓ no account (quotes delayed without a login) | ✓ your own login, with Smarkets' approved API access | polled from REST quotes | one book per contract, bids are YES bids | | | [Novig](https://novig.com) | `novig` | ✓ sports, no key | ✓ (US, identity and location checks; Paper for play money) | WebSocket (trading key), else polled from the public book | one book per market, both sides read it | | | [Matchbook](https://www.matchbook.com) | `matchbook` | ✓ no account | ✓ your own login | polled from REST | one book per runner, back and lay read as YES | | | [ProphetX](https://www.prophetx.co) | `prophetx` | ✓ sports (Trading API key pair, or a Market Data API key) | ✓ | WebSocket (Trading API key pair) | one book per line, both sides read it | @@ -93,7 +93,7 @@ Betfair is a betting exchange, not a prediction market, read here the same way: Gemini Predictions is Gemini Titan's CFTC-registered event-contract exchange. Each contract is a yes/no market named by its instrument symbol, `gemini:GEMI-NBA-2610232000-CHI-MIL-M-MIL`, and the Gemini event holding the contracts is the event. Sports events carry Gemini's taxonomy (moneyline, spread, total, prop; the sport, scope and statistic), which listings filter on. Market data needs no account; trading needs a Gemini account with Prediction Markets access (US) and an API key. See [capabilities](docs/capabilities.md#gemini). -Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting needs Smarkets' approved API access, which this library does not use yet. Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). +Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting logs in with your own Smarkets username and password, and placing orders needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). Novig, a US sports exchange, lists two-outcome markets (a moneyline, a spread, a total, a Yes/No prop), each one yes/no market, `novig:01a1577c-d666-7e81-852e-68bed1446801`, under its game as the event. Its catalog, books and trades are public and need no key. Trading signs each request with a subaccount's key; Production trades real money for verified US customers outside Arizona, Michigan and Nevada, and Paper (`NOVIG_ENV=paper`) is the same API with play money. Novig's parlay RFQ, for registered liquidity providers, is not supported yet. See [capabilities](docs/capabilities.md#novig). diff --git a/docs/api.md b/docs/api.md index 4de4820..97402ea 100644 --- a/docs/api.md +++ b/docs/api.md @@ -113,6 +113,8 @@ with its contracts, open or settled), `fetch_smarkets_events(ids)`, market id, and takes `domain` (`"football"`, `"politics"`), `scope` (`"single_event"`, `"outright"`) and `market_type` (`"WINNER_3_WAY"`) on `fetch_markets` and `fetch_events`. See [Smarkets](capabilities.md#smarkets). +Order entry is `SmarketsTrading`, with the same login; see +[trading](trading.md#smarkets). ```python smk = synpath.Smarkets() # no account; SMARKETS_USERNAME / _PASSWORD undelay the quotes diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..2d635f7 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -170,8 +170,9 @@ per 60 s (50 on `/quotes/`) for an API account. Every book and market says `SMARKETS_PASSWORD`) sends a session token (`Authorization: Session-Token`) for undelayed quotes; it lasts 30 minutes from its last use, and Smarkets allows 5 logins per 5 minutes. Accounts with two-factor login are refused with -a clear error. **Betting needs Smarkets' approved API access** (applied for, -with a one-off £150 fee); there is no Smarkets order entry here yet. +a clear error. The same login places and cancels orders with +`SmarketsTrading` (see [trading](trading.md#smarkets)); **placing orders needs +Smarkets' approved API access** (applied for, with a one-off £150 fee). Requests carry a `synpath/` User-Agent: Smarkets' Cloudflare front answers the default ones of HTTP libraries (python-httpx, curl) with a 403 @@ -527,20 +528,20 @@ install. Polymarket US has two, because the venue has two: a retail API any verified account uses, and an exchange API for onboarded firms. -| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | -| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | -| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | -| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | -| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | -| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | -| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | -| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | -| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | +| Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `smarkets` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | one at a time; `market` and `ioc` are the venue's `immediate_or_cancel` at the worst price given, `fok` the same with the whole order as its minimum; limits rest `good_til_halted` (or `keep_in_play`); no `gtd` or post-only; placing needs Smarkets' approved API access | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | +| `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | one at a time; `cancel_all_orders` per Smarkets market or account-wide, once a minute, and otherwise (or per contract) lists then cancels | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | +| `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | +| `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while Smarkets lists it (current and recently settled orders) | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | +| `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: current and recently settled orders, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | +| `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | yes, the statement's executions | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, matched unsettled orders netted per contract; the account's balance, exposure and available balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | partial: recently settled orders, before commission | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | +| `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | no | +| `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, the account's commission on what the bet wins, makers and takers alike | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | +| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; everything else -- stops, icebergs, brackets, TWAP, pegs -- is held by the diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..62e403e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -79,7 +79,7 @@ variable `synpath serve` reads it from: | predict.fun | the API key (`pred_sk_...`, from developers.predict.fun); mainnet only | `PREDICT_FUN_API_KEY` | | SX Bet | the API key (sx.bet -> Account -> Overview -> API Credentials) | `SX_BET_API_KEY` | | Betfair | the application key and a login (every Betfair call needs one) | `BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD` | -| Smarkets | nothing; an optional login undelays the quotes | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | +| Smarkets | nothing; a login undelays the quotes (the same one `SmarketsTrading` uses) | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` | | Novig | the WebSocket: a subaccount's trading key (key id and private key file; the handshake is signed). Without one the public book is polled | `NOVIG_KEY_ID`, `NOVIG_PRIVATE_KEY_PATH`, `NOVIG_ENV` | | Matchbook | nothing | | | ProphetX | the Trading API key pair (prophetx.co -> API Integration); the Market Data API key has no stream | `PROPHETX_ACCESS_KEY`, `PROPHETX_SECRET_KEY` | @@ -246,7 +246,11 @@ jittered exponential backoff between attempts. reports it `resynced`. Quotes are delayed without a login, and every event says so in `info["delayed"]`. A change shows at the next poll, not as it happens, so its `watch_*` keys are `partial`. No `watch_trades`: the recent - tape is short and unpaged; read it with `Smarkets.fetch_trades`. + tape is short and unpaged; read it with `Smarkets.fetch_trades`. There is + no account stream either (Smarkets' trading API is HTTP only), so the + engine polls `SmarketsTrading` for orders and fills; under the engine the + market stream polls on the trading adapter's session, so its quotes are + undelayed. - *Novig* carries every channel on one WebSocket, `wss://api.novig.com/v3/ws` (Paper: `wss://api.paper.novig.com/v3/ws`), opened with a subaccount's `trading` or `trading::read` key: the upgrade is signed like any request diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..15266d0 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -72,6 +72,7 @@ private key. `synpath init` tells them apart. | Limitless | nothing | the private key of the wallet connected on limitless.exchange (EOA trading mode) and a scoped API token: id and base64 secret | profile menu -> Api keys -> API Tokens -> Derive; the secret is shown once | | SX Bet | REST: nothing. Books over the WebSocket: the API key (every channel needs it) | the API key alone reads the account (orders, fills, positions, balance) and cancels; placing an order also needs the private key of the wallet you sign in with (the account's own, not its proxy wallet). The account stream needs the wallet address: from the private key, or `SX_BET_ACCOUNT_ADDRESS` | sx.bet -> Account -> Overview -> API Credentials -> Generate API key (toronto.sx.bet for the test network); shown once | | Novig | REST: nothing. Books over the WebSocket: a subaccount's trading key (without one, the public book is polled) | a subaccount's trading key: a key id and its private key file (PEM, Ed25519 or P-256). The management key, optional, opens, funds and labels subaccounts and never trades | the management key on novig.com (paper.novig.com for Paper) -> Profile -> Settings -> Novig API; then `NovigTrading.open_subaccount` with a keypair from `novig_signing.generate_key()` issues the trading key. The private key is never sent | +| Smarkets | nothing (a login undelays the quotes) | the account's login: username and password (an account with two-factor login passes a session token made elsewhere); placing orders needs Smarkets' approved API access | your smarkets.com account; API access is applied for from Smarkets (a one-off £150 fee) | | Matchbook | nothing | the account's login: username and password (an account with two-factor login passes a session token made elsewhere) | your matchbook.com account; higher API limits need an API plan from api@matchbook.com | | ProphetX | the Trading API key pair, or a Market Data API key | the Trading API key pair: an access key and a secret key, exchanged for a ten-minute bearer token | prophetx.co -> Menu -> API Integration, on any KYC-approved account (sandbox.prophetx.dev for the sandbox); the secret is shown once. The Market Data API key is issued by ProphetX on request | | Azuro | nothing | the private key of the wallet that signs each bet and holds the bet token, which must allow Azuro's relayer to spend it (`approve()`, once) | exported from the wallet | @@ -103,6 +104,7 @@ The variables each venue reads: | Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | +| Smarkets | `SMARKETS_USERNAME`, `SMARKETS_PASSWORD` (betting and the account's orders, statement and balance; market data needs neither, and with them its quotes are undelayed) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | | ProphetX | `PROPHETX_ACCESS_KEY` and `PROPHETX_SECRET_KEY` (the Trading API: market data, streams, orders), `PROPHETX_AFFILIATE_KEY` (optional; the Market Data API, market data only), `PROPHETX_SANDBOX=1` (optional; ProphetX's sandbox, with demo funds) | | Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance and allowance are read from and the approval is sent through; a public one when unset) | @@ -115,10 +117,9 @@ and the stream only: there is no Betfair order entry yet. See [Betfair](capabilities.md#betfair). Smarkets needs no credentials for market data. `SMARKETS_USERNAME` and -`SMARKETS_PASSWORD` are optional: they log in for undelayed quotes, and -`synpath doctor` reports them. There is no Smarkets order entry yet: betting -through Smarkets' API needs its approved API access (applied for, with a -one-off £150 fee). See [Smarkets](capabilities.md#smarkets). +`SMARKETS_PASSWORD` log in for undelayed quotes and for order entry; placing +orders through Smarkets' API needs its approved API access (applied for, with +a one-off £150 fee). See [Smarkets](#smarkets). Novig trades on one subaccount per trading key. An order buys an outcome: `buy` the market's YES outcome at the price given, `sell` its NO outcome at @@ -613,6 +614,85 @@ free to bet, and what matched bets hold in escrow. Check SX Bet's terms for where it may be used before trading. +## Smarkets + +`SmarketsTrading`, built to Smarkets' trading API reference +(docs.smarkets.com, API v3). Every call carries the session token of an +account login (`SMARKETS_USERNAME`, `SMARKETS_PASSWORD`), made on the first +call and kept in memory only, as `Authorization: Session-Token `. A +token lasts 30 minutes from its last use: one idle for 25 is replaced before +the next call, and a 401 logs in again once. Smarkets allows five logins per +five minutes. An account with two-factor login cannot log in from here; pass +`session_token=` from a login made elsewhere. **Placing orders needs +Smarkets' approved API access** (applied for, with a one-off £150 fee): any +account reads its balance, orders and statement, and an account without it +is refused a new order with `PermissionDenied`. + +An order buys or sells one contract's YES, `smarkets:-`. `buy` backs the contract (takes its YES), `sell` lays it (takes its +NO), and the price is the YES price, as Smarkets' book reads: its `bids` are +resting backs, YES bids. Smarkets prices in basis points of 100%, and only on +its odds ladder (decimal odds 1.01 to 1000 in Betfair's bands, as basis +points, and the ends 1 and 9999). A price within half a basis point of a tick +is that tick; any other is snapped onto the ladder on the cheaper side: a buy +at the tick below, a sell at the tick above. An order's size is its pot (back +stake plus lay liability) in 1/10,000 of the account's currency, so `n` +contracts send `n * 10,000`, rounded down; the smallest back stake is £0.05. + +A limit is `good_til_halted` by default, Smarkets' own default: it rests +through the market going in play and is cancelled when the market halts (a +goal, a red card). `params={"order_type": "keep_in_play"}` (or +`{"keep_in_play": True}`) keeps it through halts as well, and +`"good_til_cancelled"` cancels it when the market goes in play. Smarkets +cancels every resting order when it applies a reduction factor (a withdrawn +runner). `market` and `ioc` are `immediate_or_cancel` at the price given as +the worst accepted, answered once they have matched what they could; `fok` is +the same with `minimum_accepted_quantity` set to the whole order, and +`params={"minimum_quantity": n}` sets a smaller floor on an `ioc`. One that +cannot meet its floor is refused (`OrderRejected`, +`ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY`). `gtd`, `day`, post-only and +`reduce_only` are refused. There is no amend route, so `edit_order` is not +offered; cancel and replace. `create_orders` and `cancel_orders` send one +request per order and return one result each. + +Smarkets takes no client order id, but every order carries a free `label`: +`client_order_id` (or `params["label"]`) is sent as the label and read back +as `client_order_id`. After a timeout, list the open orders on the contract +(`fetch_open_orders(market_id=...)`) and match the label to see what was +placed. + +Orders are read from `GET /v3/orders/`, which lists current and recently +settled orders: `fetch_order`, `fetch_open_orders`, `fetch_orders` (by +status, oldest first) and `fetch_settlements` (settled orders, one per +contract and side, with `payout` and `pnl` before commission). Smarkets has +no cancelled state: an order with nothing left unmatched that did not match +in full is `canceled`. `cancel_order` reads the order back; one the exchange +has not finished cancelling reads `pending_cancel`. `cancel_all_orders` +cancels the account's orders, or a Smarkets market's +(`smarkets:`), in one request that Smarkets acknowledges without +a count, once a minute; a second inside the minute, or one naming a single +contract, cancels the open orders one by one and returns how many. In play, +matched bets wait about eight seconds for confirmation and can be voided in +that time; the pending part counts as filled (`info["quantity_filled_pending"]`). + +Fills are the account statement's executions (`order.execute` rows of +`/v3/accounts/activity/`), newest first. Positions net the account's matched, +unsettled orders per contract on the YES leg: backs are YES contracts, lays +NO contracts, and `margin` is the most the contract can lose. The balance is +the account's cash, what its bets hold (`exposure`, `locked`), and what is +free to bet, in the account's currency. Commission is charged at settlement +on net winnings per Smarkets market, 2% on the standard tier; fee estimates +use the account's own rate (`commission_type`). + +Smarkets allows 1,200 requests a minute per account, ten order placements +and ten cancels a second, and one cancel-all a minute; the adapter paces +itself inside them. Smarkets' trading API is HTTP only (its socket feed is +for approved market makers), so there is no account stream and the engine +polls the orders. + +Check Smarkets' terms for where it may be used before trading: its API terms +allow viewing prices and betting on your own account only. + ## Matchbook `MatchbookTrading`, built to Matchbook's API reference diff --git a/synpath/__init__.py b/synpath/__init__.py index ff88c20..d5484fa 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -127,7 +127,7 @@ def exchange(venue: str, **kwargs) -> Exchange: # keeps working. Imported last, after the read API it builds on. from .trading import ( TradingExchange, KalshiTrading, PolymarketTrading, PolymarketUSTrading, PolymarketUSExchangeTrading, - OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, MatchbookTrading, ProphetXTrading, AzuroTrading, MyriadTrading, GeminiTrading, + OpinionTrading, HyperliquidTrading, PredictFunTrading, LimitlessTrading, SxBetTrading, NovigTrading, SmarketsTrading, MatchbookTrading, ProphetXTrading, AzuroTrading, MyriadTrading, GeminiTrading, Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, @@ -196,7 +196,7 @@ def __getattr__(name: str): "MarketNotFound", "NotSupported", "AuthenticationError", # order entry "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", - "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", "SmarketsTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/client.py b/synpath/client.py index a19ec85..d3f5a33 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -388,6 +388,11 @@ def _novig(creds: Credentials) -> TradingExchange: return NovigTrading(creds) # type: ignore[arg-type] +def _smarkets(creds: Credentials) -> TradingExchange: + from .trading.smarkets import SmarketsTrading + return SmarketsTrading(creds) # type: ignore[arg-type] + + def _matchbook(creds: Credentials) -> TradingExchange: from .trading.matchbook import MatchbookTrading return MatchbookTrading(creds) # type: ignore[arg-type] @@ -417,6 +422,7 @@ def _gemini(creds: Credentials) -> TradingExchange: "kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us, "opinion": _opinion, "hyperliquid": _hyperliquid, "predict_fun": _predict_fun, "limitless": _limitless, "sx_bet": _sx_bet, "novig": _novig, + "smarkets": _smarkets, "matchbook": _matchbook, "prophetx": _prophetx, "azuro": _azuro, diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index 352385a..82f5755 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -66,6 +66,7 @@ "limitless": ("..trading.limitless", "LimitlessTrading"), "sx_bet": ("..trading.sx_bet", "SxBetTrading"), "novig": ("..trading.novig", "NovigTrading"), + "smarkets": ("..trading.smarkets", "SmarketsTrading"), "matchbook": ("..trading.matchbook", "MatchbookTrading"), "prophetx": ("..trading.prophetx", "ProphetXTrading"), "azuro": ("..trading.azuro", "AzuroTrading"), diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index a4672bf..b86aa57 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -182,11 +182,13 @@ def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any] private=GeminiUserStream(creds) if creds is not None else None, ) elif venue == "smarkets": - # Market data only, and public: a polled stream on REST quotes - # (Smarkets' socket stream is for approved market makers). The - # login, when configured, only undelays the quotes. + # A polled stream on REST quotes (Smarkets' socket stream is for + # approved market makers), on the trading adapter's session when + # there is one, so the login undelays the quotes. No account + # stream; the engine polls the orders. from ..ws.smarkets import SmarketsMarketStream - out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) + session = getattr(adapter, "session", None) + out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds, session=session), private=None) elif venue == "novig": # The websocket takes a subaccount's trading key; without one the # market stream polls the public book. diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 462b90e..bd13ee2 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -36,7 +36,7 @@ log = logging.getLogger("synpath.serve") VENUES = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "novig", - "matchbook", "prophetx", "azuro", "myriad", "gemini") + "smarkets", "matchbook", "prophetx", "azuro", "myriad", "gemini") def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]: diff --git a/synpath/smarkets.py b/synpath/smarkets.py index d1698f9..63c616f 100644 --- a/synpath/smarkets.py +++ b/synpath/smarkets.py @@ -7,8 +7,8 @@ records which in its `info` (`delayed`). An optional login (`SMARKETS_USERNAME`, `SMARKETS_PASSWORD`) sends a session token for undelayed quotes and the account's own limits; see `SmarketsSession`. -Betting needs approved API access from Smarkets (a one-off £150 fee) and is -not offered here yet. +Order entry is `synpath.trading.smarkets.SmarketsTrading`, on the same login; +placing orders needs approved API access from Smarkets (a one-off £150 fee). Five things about this venue that shape the adapter: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index a7a2365..a052864 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -84,6 +84,9 @@ def __getattr__(name: str): if name == "NovigTrading": from .novig import NovigTrading return NovigTrading + if name == "SmarketsTrading": + from .smarkets import SmarketsTrading + return SmarketsTrading if name == "MatchbookTrading": from .matchbook import MatchbookTrading return MatchbookTrading @@ -105,7 +108,7 @@ def __getattr__(name: str): __all__ = [ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading", "OpinionTrading", "HyperliquidTrading", "PredictFunTrading", "LimitlessTrading", "SxBetTrading", "NovigTrading", - "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", + "SmarketsTrading", "MatchbookTrading", "ProphetXTrading", "AzuroTrading", "MyriadTrading", "GeminiTrading", "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", diff --git a/synpath/trading/__main__.py b/synpath/trading/__main__.py index 1746aae..5b0de07 100644 --- a/synpath/trading/__main__.py +++ b/synpath/trading/__main__.py @@ -61,9 +61,9 @@ def doctor(dotenv: str | None) -> int: for venue, creds in loaded.items(): if creds is None and venue == "smarkets": # Optional: Smarkets serves its market data without a login. - rows.append((venue, "no login (optional)", "market data needs none; set SMARKETS_USERNAME / " - "SMARKETS_PASSWORD for undelayed quotes, or run synpath init. Betting needs Smarkets' " - "approved API access (£150 one-off), not supported yet")) + rows.append((venue, "not configured", "market data needs no login; set SMARKETS_USERNAME / " + "SMARKETS_PASSWORD to bet and for undelayed quotes, or run synpath init. Placing orders " + "needs Smarkets' approved API access")) continue if creds is None and venue == "matchbook": # Market data is public; the login is for betting. @@ -144,7 +144,7 @@ def doctor(dotenv: str | None) -> int: identity = f"api key {creds.api_key[:12]}…{sub} (account-scoped, time-based nonce for streams)" elif venue == "smarkets": stage = "login" - identity = f"user {creds.username}: undelayed quotes; betting needs approved API access, not supported yet" + identity = f"user {creds.username}: undelayed quotes and the account; placing orders needs approved API access" elif venue == "matchbook": stage = "login" identity = f"user {creds.username}" diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..1b47c31 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -48,8 +48,10 @@ Betfair BETFAIR_APP_KEY, BETFAIR_USERNAME, BETFAIR_PASSWORD, and (market data) optionally BETFAIR_CERT_PATH / BETFAIR_KEY_PATH (certificate login) and BETFAIR_JURISDICTION - Smarkets optionally SMARKETS_USERNAME and SMARKETS_PASSWORD: market - (market data) data needs no login; one gives undelayed quotes + Smarkets SMARKETS_USERNAME, SMARKETS_PASSWORD: betting and the + account log in (placing orders needs Smarkets' approved + API access); market data needs no login, and one gives + undelayed quotes Novig NOVIG_KEY_ID, NOVIG_PRIVATE_KEY_PATH (a subaccount's trading key, PEM, Ed25519 or P-256), NOVIG_ENV (production, the default, or paper), and optionally NOVIG_MANAGEMENT_KEY_ID / @@ -403,9 +405,11 @@ def secrets(self) -> list[str]: @dataclass(frozen=True, repr=False) class SmarketsCredentials: - """Smarkets: an optional account login. Market data needs none; a session - from it gives undelayed quotes and the account's own rate limits. Betting - needs Smarkets' approved API access, which this library does not use yet.""" + """Smarkets: the account's login. Betting and the account's reads (orders, + statement, balance) log in with it; market data needs none, and a session + from it gives undelayed quotes and the account's own rate limits. Placing + orders needs Smarkets' approved API access. An account with two-factor + login cannot log in from here (pass a session token instead).""" username: str password: str = field(repr=False) diff --git a/synpath/trading/init.py b/synpath/trading/init.py index c4da301..ad39b58 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -293,9 +293,9 @@ def ask_betfair(p: Prompter) -> dict[str, str]: def ask_smarkets(p: Prompter) -> dict[str, str]: - p.say("\nSmarkets: market data needs no login at all. A login (optional) sends a session token for") - p.say("undelayed quotes and your account's own rate limits. Betting needs Smarkets' approved API access") - p.say("(a one-off £150 fee), which this library does not support yet. The password is typed hidden.") + p.say("\nSmarkets: market data needs no login at all. A login sends a session token for undelayed") + p.say("quotes, your account's own rate limits and its orders, balance and positions. Placing orders") + p.say("needs Smarkets' approved API access (a one-off £150 fee). The password is typed hidden.") username = p.value("Smarkets username (your email)", check=_required("The username")) password = p.value("Smarkets password (typed hidden)", secret=True, check=_required("The password")) return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} @@ -390,7 +390,7 @@ def ask_gemini(p: Prompter) -> dict[str, str]: ("Limitless", ("LIMITLESS_PRIVATE_KEY",), ask_limitless), ("SX Bet", ("SX_BET_API_KEY", "SX_BET_PRIVATE_KEY"), ask_sx_bet), ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), - ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), + ("Smarkets (market data needs no login)", ("SMARKETS_USERNAME",), ask_smarkets), ("Novig", ("NOVIG_KEY_ID", "NOVIG_MANAGEMENT_KEY_ID"), ask_novig), ("Matchbook", ("MATCHBOOK_USERNAME",), ask_matchbook), ("ProphetX", ("PROPHETX_ACCESS_KEY", "PROPHETX_AFFILIATE_KEY"), ask_prophetx), diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 2170ae7..6c31051 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -60,11 +60,11 @@ # in the account's currency, to the penny, at least 1 (GBP) a bet. "betfair": {"default_tick": Decimal("0.00001"), "min_quantity": Decimal("0.01"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("1")}, - # No order entry yet; written down for when there is. Prices are basis - # points of 100% on an odds ladder (5000, 4762, 4545 are odds 2.0, 2.1, - # 2.2), so the tick here is the finest, one basis point. Quantities are - # pots in 1/10,000 GBP, so contracts step by 0.0001; the minimum stake is - # £0.05. + # Prices are basis points of 100% on an odds ladder (5000, 4762, 4545 are + # odds 2.0, 2.1, 2.2), so the tick here is the finest, one basis point, + # and the trading adapter snaps each order onto the ladder. Quantities + # are pots in 1/10,000 of the account's currency, so contracts step by + # 0.0001; the minimum back stake is £0.05. "smarkets": {"default_tick": Decimal("0.0001"), "min_quantity": Decimal("0.0001"), "quantity_step": Decimal("0.0001"), "whole": False, "min_notional": Decimal("0.05")}, # Prices on a grid of 0.001 near the ends (to 0.050, from 0.950) and diff --git a/synpath/trading/smarkets.py b/synpath/trading/smarkets.py new file mode 100644 index 0000000..5270eb4 --- /dev/null +++ b/synpath/trading/smarkets.py @@ -0,0 +1,921 @@ +"""Smarkets order entry: orders placed and cancelled, and the account's +orders, executions, positions and balance on the YES leg. + +Built to Smarkets' trading API reference (docs.smarkets.com, the OpenAPI +document at `api.smarkets.com/v0/control/openapi/`); its records follow the +reference's examples. Every call carries the session token of an account +login (`SMARKETS_USERNAME`, `SMARKETS_PASSWORD`); see +`synpath.smarkets.SmarketsSession`. **Placing orders needs Smarkets' approved +API access** (applied for, with a one-off £150 fee): an ordinary account +reads its balance, orders and statement here, and is refused a new order +with `PermissionDenied`. + +**An order buys or sells one contract's YES.** `buy` backs the contract +(takes its YES), `sell` lays it (takes its NO), and the price is the YES +price, as Smarkets' own `bids` (resting backs) are YES bids. Smarkets prices +in basis points of 100% (5000 = 50%), so there are no odds to invert. + +**Quantities are pots.** An order's `quantity` is what the pot (back stake +plus lay liability) would be if it matched, in 1/10,000 of the account's +currency; a pot of 1 pays 1 to the winning side, so it is one contract. An +order for `n` contracts sends `n * 10,000`, rounded down. The minimum is a +back stake of £0.05 (or the currency equivalent), which Smarkets checks. + +**Prices sit on an odds ladder.** Smarkets accepts only prices that are +decimal odds on the same bands as Betfair's (1.01 to 1000) turned into +basis points, plus the ends, 1 and 9999; every price in its public quotes +lies on it. A price within half a basis point of a tick is that tick; any +other is snapped onto the ladder on the cheaper side: a buy at the tick +below (it pays no more), a sell at the tick above (it receives no less). + +**Order types.** A limit is `good_til_halted` by default (Smarkets' own +default): it rests through the market going in play and is cancelled when +the market halts (a goal, a red card). `params={"order_type": +"keep_in_play"}` (or `{"keep_in_play": True}`) keeps it through halts too; +`"good_til_cancelled"` cancels it when the market goes in play. Every +resting order is cancelled when a reduction factor is applied (a +withdrawn runner). A market order or an IOC limit is `immediate_or_cancel` +at the price given as the worst accepted; a FOK is the same with +`minimum_accepted_quantity` set to the whole order, so nothing matches +unless all of it can. GTD, DAY and post-only orders are not offered. + +**No client order id; the label stands in.** Smarkets assigns the order id. +`client_order_id` (or `params["label"]`) is sent as the order's `label`, a +free string Smarkets keeps on the order and its statement rows, and is read +back as `client_order_id`. After a timeout, list the open orders and match +the label to find what was placed. + +**Matched bets can be pending.** In play, a matched bet waits eight seconds +(longer after a goal or a red card) before it is confirmed and can be voided +in that time; the pending part is counted as filled and rides in `info` +(`quantity_filled_pending`). + +**Positions** net per contract on the YES leg from the account's current +(unsettled) orders: backs matched are YES contracts, lays NO contracts. +Smarkets has no positions route. + +**Fees** are commission on the account's net winnings per Smarkets market at +settlement (2% on the standard tier; the account's rate is `commission_type` +on `/v3/accounts/`). + +**Rate limits.** 1200 requests per 60 s per account; placing and cancelling +one order 10 a second each; cancelling everything once a minute. A second +cancel-all inside the minute cancels the open orders one by one instead. + +There is no account stream: Smarkets' socket feed is for approved market +makers and its trading API is HTTP only, so the engine polls the orders. +""" +from __future__ import annotations + +import asyncio +import bisect +import time +from decimal import ROUND_DOWN, ROUND_HALF_EVEN, Decimal +from typing import Any +from urllib.parse import parse_qs + +from .. import ids +from ..base import AsyncHttpClient, Capability +from ..betfair import CLASSIC_LADDER +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NetworkError, RateLimitExceeded +from ..smarkets import ( + API_URL, DEFAULT_COMMISSION, PRICE_SCALE, QUANTITY_SCALE, SmarketsSession, contract_native_id, error_type, + fee_schedule_of, iso_ms, market_of, parse_contract_id, user_agent, +) +from ..types import Page +from .base import TradingExchange +from .credentials import SmarketsCredentials +from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, PermissionDenied +from .limiter import BudgetLimiter, Priority +from .types import ( + Account, Balance, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, + PositionSide, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES, +) + +VENUE = "smarkets" +ONE = Decimal("1") +SCALE = Decimal(PRICE_SCALE) +UNITS = Decimal(QUANTITY_SCALE) +PRICE_PLACES = Decimal("0.0000000001") + +MAX_QUANTITY = 4_294_967_295 +"""The largest `quantity` an order takes, in 1/10,000 of the currency.""" + +PER_PAGE = 100 +"""Orders read per request when listing, Smarkets' maximum.""" + +ACTIVITY_PER_PAGE = 500 +"""Statement rows read per request, Smarkets' maximum.""" + +MAX_PAGES = 50 +"""Most pages one listing call reads.""" + +CANCEL_ALL_INTERVAL_S = 60.0 +"""Smarkets allows one cancel-all request per 60 s.""" + +RESTING_TYPES = ("good_til_halted", "keep_in_play", "good_til_cancelled") +"""Smarkets' resting order types: cancelled when the market halts, kept +through halts, cancelled when the market goes in play.""" + +OPEN_STATES = ("created", "partial") +CURRENT_STATES = ("created", "partial", "filled") +"""Smarkets order states: unmatched, partly matched, fully matched, then +`settled` once the contract has its outcome.""" + +OUTCOMES = {"winner": "yes", "loser": "no", "deadheat": "dead_heat", "reduce": "void", "void": "void"} +"""A settled order's contract outcome as the YES result, as the read adapter +names them.""" + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return Decimal("0") + return Decimal(str(value)) + + +def _ladder() -> tuple[int, ...]: + ticks = {int((SCALE / odds).quantize(ONE, rounding=ROUND_HALF_EVEN)) for odds in CLASSIC_LADDER} + return tuple(sorted(ticks | {1, PRICE_SCALE - 1})) + + +PRICE_LADDER: tuple[int, ...] = _ladder() +"""Every price Smarkets accepts, in basis points: the decimal odds 1.01 to +1000 in Betfair's bands, each as 10,000 / odds rounded to the nearest basis +point (half to even), with 1 and 9999 at the ends.""" +_TICKS = frozenset(PRICE_LADDER) + + +def price_bps(side: Side, price: Any) -> int: + """The ladder price in basis points for an order at this YES price: the + tick it names (within half a basis point), else the next tick on the + cheaper side, below for a buy and above for a sell.""" + p = D(price) + if not 0 < p < 1: + raise InvalidOrder(f"{VENUE}: a price is a YES probability strictly between 0 and 1, not {price}") + raw = p * SCALE + nearest = int(raw.quantize(ONE, rounding=ROUND_HALF_EVEN)) + if nearest in _TICKS: + return nearest + if side == Side.BUY: + index = bisect.bisect_right(PRICE_LADDER, raw) - 1 + if index < 0: + raise InvalidOrder(f"{VENUE}: {price} is below Smarkets' lowest price (0.0001)") + return PRICE_LADDER[index] + index = bisect.bisect_left(PRICE_LADDER, raw) + if index >= len(PRICE_LADDER): + raise InvalidOrder(f"{VENUE}: {price} is above Smarkets' highest price (0.9999)") + return PRICE_LADDER[index] + + +def quantity_units(quantity: Any) -> int: + """`quantity` contracts as Smarkets' pot, in 1/10,000 of the currency, + rounded down.""" + units = int((D(quantity) * UNITS).to_integral_value(rounding=ROUND_DOWN)) + if units <= 0: + raise InvalidOrder(f"{VENUE}: {quantity} contracts is less than Smarkets' smallest quantity (0.0001)") + if units > MAX_QUANTITY: + raise InvalidOrder(f"{VENUE}: {quantity} contracts is more than one order takes") + return units + + +def yes_price(bps: Any) -> Decimal: + """Basis points of 100% (possibly fractional) as the YES price.""" + return (D(bps) / SCALE).quantize(PRICE_PLACES).normalize() + + +def contracts(units: Any) -> Decimal: + """A pot in 1/10,000 of the currency as contracts paying 1 each.""" + return D(units) / UNITS + + +def _units(row: dict[str, Any], name: str) -> Any: + """A quantity in the account's currency where the row has one, else the + GBP figure (the same thing on a GBP account).""" + value = row.get(f"{name}_user_currency") + return value if value is not None else row.get(name) + + +def _market(row: dict[str, Any]) -> str: + return ids.qualify(VENUE, contract_native_id(row.get("market_id"), row.get("contract_id"))) + + +def side_of(native: Any) -> Side: + """`buy` backs the contract (YES); `sell` lays it (NO).""" + return Side.SELL if str(native or "").lower() == "sell" else Side.BUY + + +# --------------------------------------------------------------------------- +# Records +# --------------------------------------------------------------------------- + +def filled_units(row: dict[str, Any]) -> Decimal: + """What matched, in units: `quantity_filled`, plus the part still pending + confirmation where Smarkets counts it apart (filled, pending and + unfilled then add up to the order).""" + filled, pending = D(_units(row, "quantity_filled")), D(_units(row, "quantity_filled_pending")) + unfilled, total = D(_units(row, "quantity_unfilled")), D(_units(row, "quantity")) + if pending and filled + pending + unfilled == total: + filled += pending + return filled + + +def order_of_row(row: dict[str, Any], *, account: Account | None = None) -> Order: + """One order as `GET /v3/orders/` reports it. + + Smarkets has no cancelled state: an order in `created` or `partial` with + nothing left unmatched was cancelled (or was an immediate order's + unmatched rest), and a `settled` order that did not match in full had + its rest cancelled before it settled.""" + quantity = contracts(_units(row, "quantity")) + filled = contracts(filled_units(row)) + unfilled = contracts(_units(row, "quantity_unfilled")) + state = str(row.get("state") or "") + if state == "filled": + status = OrderStatus.CLOSED + elif state in OPEN_STATES or state == "settled": + if state != "settled" and unfilled > 0: + status = OrderStatus.OPEN + else: + status = OrderStatus.CLOSED if filled >= quantity else OrderStatus.CANCELED + else: + status = OrderStatus.PENDING + native_type = str(row.get("type") or "") + immediate = native_type in ("immediate_or_cancel", "over_the_counter", "over_the_counter_free") + average = row.get("average_price_matched_precise") or row.get("average_price_matched") + return Order( + id=str(row.get("id") or ""), + client_order_id=row.get("label") or None, + venue=VENUE, + account=account, + market_id=_market(row), + side=side_of(row.get("side")), + type=OrderType.LIMIT, + time_in_force=TimeInForce.IOC if immediate else TimeInForce.GTC, + status=status, + price=yes_price(row["price"]) if row.get("price") else None, + quantity=quantity, + filled=filled, + remaining=unfilled if status in (OrderStatus.OPEN, OrderStatus.PENDING) else Decimal("0"), + average_price=yes_price(average) if filled and average and D(average) > 0 else None, + created_at=iso_ms(row.get("created_datetime")), + updated_at=iso_ms(row.get("last_modified_datetime")), + info=row, + ) + + +def immediate(request: OrderRequest) -> bool: + """Whether the order takes what is there and leaves nothing resting.""" + return request.type == OrderType.MARKET or request.time_in_force in (TimeInForce.IOC, TimeInForce.FOK) + + +def order_after_placing(request: OrderRequest, row: dict[str, Any], *, account: Account) -> Order: + """The order from Smarkets' answer to `POST /v3/orders/`, with what the + request carried that the answer does not. An immediate order's answer + comes once it has matched what it could and been cancelled; any other + once the exchange accepted it.""" + quantity = contracts(row.get("quantity")) + executed = contracts(row.get("total_executed_quantity")) + if executed >= quantity > 0: + status, remaining = OrderStatus.CLOSED, Decimal("0") + elif immediate(request): + status, remaining = OrderStatus.CANCELED, Decimal("0") + else: + status = OrderStatus.OPEN + left = row.get("available_quantity") + remaining = contracts(left) if left is not None else quantity - executed + average = row.get("executed_avg_price") + now = int(time.time() * 1000) + return Order( + id=str(row.get("order_id") or ""), + client_order_id=request.client_order_id, + venue=VENUE, + account=request.account or account, + market_id=_market(row) if row.get("market_id") else ids.qualify(VENUE, ids.native(VENUE, request.market_id)), + side=side_of(row.get("side")) if row.get("side") else request.side, + type=request.type, + time_in_force=request.time_in_force, + status=status, + price=yes_price(row["price"]) if row.get("price") else None, + quantity=quantity, + filled=executed, + remaining=remaining, + average_price=yes_price(average) if executed and average else None, + created_at=now, + updated_at=now, + book=request.book, + trader=request.trader, + tags=request.tags, + info=row, + ) + + +def fill_of_activity(row: dict[str, Any], *, account: Account | None = None) -> Fill: + """One `order.execute` statement row as a fill: its price on the YES + leg, its pot in contracts.""" + units = _units(row, "quantity") + return Fill( + id=f"{row.get('seq')}-{row.get('subseq')}", + order_id=str(row.get("order_id") or ""), + client_order_id=row.get("label") or None, + venue=VENUE, + account=account, + market_id=_market(row), + side=side_of(row.get("side")), + price=yes_price(row.get("price")), + quantity=abs(contracts(units)), + fee=D(row["commission"]) if row.get("commission") is not None else None, + liquidity=Liquidity.UNKNOWN, + timestamp=iso_ms(row.get("timestamp")) or 0, + info=row, + ) + + +def _matched(row: dict[str, Any]) -> tuple[Decimal, Decimal]: + """(contracts matched, their average YES price) of one order.""" + filled = contracts(filled_units(row)) + average = row.get("average_price_matched_precise") or row.get("average_price_matched") or row.get("price") + return filled, (D(average) / SCALE if average else Decimal("0")) + + +def positions_of_orders(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: + """Unsettled orders netted per contract on the YES leg: matched backs are + YES contracts, matched lays NO contracts. `margin` is what the contract + loses at worst: what was paid (a back's stake, a lay's liability) less + what the losing side's bets return.""" + per: dict[str, dict[str, Any]] = {} + for row in rows: + if str(row.get("state") or "") == "settled": + continue + filled, price = _matched(row) + if filled <= 0: + continue + entry = per.setdefault(_market(row), {"yes": Decimal("0"), "no": Decimal("0"), "cost": Decimal("0"), + "traded": Decimal("0"), "orders": []}) + if side_of(row.get("side")) == Side.BUY: + entry["yes"] += filled + entry["cost"] += filled * price + else: + entry["no"] += filled + entry["cost"] += filled * (ONE - price) + entry["traded"] += filled * price + entry["orders"].append(row) + out = [] + for market_id, entry in per.items(): + yes, no, cost = entry["yes"], entry["no"], entry["cost"] + net = yes - no + worst = max(cost - yes, cost - no) + out.append(Position( + venue=VENUE, + account=account, + market_id=market_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), + inventory_yes=yes, + inventory_no=no, + # The average YES price of every matched bet, backs and lays. + entry_price=(entry["traded"] / (yes + no)).quantize(PRICE_PLACES), + margin=max(Decimal("0"), worst), + info={"orders": entry["orders"]}, + )) + return out + + +def settlements_of_orders(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Settlement]: + """Settled orders, one settlement per contract and side the account + matched. `payout` and `pnl` are before commission, which Smarkets takes + on the net winnings of the whole Smarkets market.""" + groups: dict[tuple[str, Side], list[dict[str, Any]]] = {} + for row in rows: + if str(row.get("state") or "") != "settled": + continue + if contracts(filled_units(row)) <= 0: + continue + groups.setdefault((_market(row), side_of(row.get("side"))), []).append(row) + out = [] + for (market_id, side), group in groups.items(): + quantity, cost = Decimal("0"), Decimal("0") + for row in group: + filled, price = _matched(row) + quantity += filled + cost += filled * (price if side == Side.BUY else ONE - price) + outcomes = {str(row.get("outcome") or "") for row in group} - {""} + result = OUTCOMES.get(outcomes.pop()) if len(outcomes) == 1 else None + won: bool | None = None + payout: Decimal | None = None + if result in ("yes", "no"): + won = (result == "yes") == (side == Side.BUY) + payout = quantity if won else Decimal("0") + elif result == "void": + payout = cost + stamps = [iso_ms(row.get("last_modified_datetime")) for row in group] + out.append(Settlement( + venue=VENUE, + account=account, + market_id=market_id, + held=PositionSide.LONG if side == Side.BUY else PositionSide.SHORT, + result=result, + won=won, + quantity=quantity, + cost=cost, + payout=payout, + pnl=payout - cost if payout is not None else None, + timestamp=max((s for s in stamps if s), default=None), + info={"orders": group, "pnl": "before commission"}, + )) + return out + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +def error_of(exc: ExchangeError) -> ExchangeError: + """Smarkets' refusal of an order as a typed error, by its `error_type`.""" + code = error_type(exc.body) or "" + text = f"{VENUE}: order refused: {code or exc}" + if "INSUFFICIENT_FUNDS" in code: + return InsufficientFunds(text, body=exc.body, status=exc.status) + if any(word in code for word in ("MARKET_HALTED", "TRADING_SUSPENDED", "MARKET_NOT_OPEN", "CONTRACT_SETTLED", + "MARKET_SETTLED")): + return MarketHalted(text, body=exc.body, status=exc.status) + if "INVALID_PRICE" in code or "INVALID_QUANTITY" in code or code in ( + "REQUEST_VALIDATION_ERROR", "ORDER_REJECTED_CONTRACT_NOT_FOUND", "ORDER_REJECTED_MARKET_NOT_FOUND"): + return InvalidOrder(text) + return OrderRejected(text, reason=code or None, info=exc.body if isinstance(exc.body, dict) else {}, + body=exc.body, status=exc.status) + + +def _next(next_page: Any, key: str) -> str | None: + """A paging value from Smarkets' `next_page` query string.""" + if not next_page: + return None + values = parse_qs(str(next_page).lstrip("?")).get(key) + return values[-1] if values else None + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + +def check_request(request: OrderRequest) -> None: + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder( + f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; submit it through the engine" + ) + if request.time_in_force in (TimeInForce.GTD, TimeInForce.DAY): + raise InvalidOrder(f"{VENUE}: Smarkets orders have no expiry; gtc, ioc or fok only") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") + if request.post_only: + raise InvalidOrder(f"{VENUE}: Smarkets' API has no post-only order") + if request.reduce_only: + raise InvalidOrder(f"{VENUE}: Smarkets nets every bet on a contract; there is no reduce-only order") + + +def venue_type(request: OrderRequest) -> str: + """Smarkets' order type for a request; see the module docstring.""" + if immediate(request): + return "immediate_or_cancel" + chosen = request.params.get("order_type") + if chosen is None: + chosen = "keep_in_play" if request.params.get("keep_in_play") else "good_til_halted" + if chosen not in RESTING_TYPES: + raise InvalidOrder(f"{VENUE}: order_type is one of {', '.join(RESTING_TYPES)}, not {chosen!r}") + return str(chosen) + + +def build_order(request: OrderRequest) -> dict[str, Any]: + """One request as Smarkets' `POST /v3/orders/` body, before anything is sent.""" + check_request(request) + market_id, contract_id = parse_contract_id(ids.native(VENUE, request.market_id)) + units = quantity_units(request.quantity) + body: dict[str, Any] = { + "market_id": market_id, + "contract_id": contract_id, + "side": "buy" if request.side == Side.BUY else "sell", + "price": price_bps(request.side, request.price), + "quantity": units, + "type": venue_type(request), + } + if request.time_in_force == TimeInForce.FOK: + body["minimum_accepted_quantity"] = units + elif immediate(request) and request.params.get("minimum_quantity") is not None: + body["minimum_accepted_quantity"] = min(units, quantity_units(request.params["minimum_quantity"])) + label = request.params.get("label") or request.client_order_id + if label: + body["label"] = str(label) + return body + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class SmarketsTrading(TradingExchange): + """Smarkets order entry. + + ```python + from synpath.trading.credentials import load_credentials, require + from synpath.trading.smarkets import SmarketsTrading + + async with SmarketsTrading(require("smarkets", load_credentials())) as smk: + print(await smk.fetch_balance()) + ``` + """ + + id = VENUE + name = "Smarkets" + has: dict[str, Capability] = { + "create_order": True, + # One request per order; not atomic. + "create_orders": True, + "cancel_order": True, + # One request per order. + "cancel_orders": True, + # Everywhere or per Smarkets market, once a minute; per contract by + # listing its open orders and cancelling each. + "cancel_all_orders": True, + # No amend route: cancel and replace. + "edit_order": False, + "fetch_order": True, + "fetch_open_orders": True, + # Smarkets keeps current and recently settled orders here. + "fetch_orders": "partial", + # From the account statement's executions. + "fetch_my_trades": True, + "fetch_positions": True, + "fetch_balance": True, + # Recently settled orders only. + "fetch_settlements": "partial", + "fetch_queue_position": False, + "fetch_fee_estimate": True, + "rfq": False, + "split_merge": False, + "watch_orders": False, + "watch_my_trades": False, + "watch_positions": False, + "watch_balance": False, + } + + def __init__( + self, + credentials: SmarketsCredentials | None = None, + *, + session: SmarketsSession | None = None, + session_token: str | None = None, + account_name: str = "default", + url: str = API_URL, + commission: float | None = None, + limiter: BudgetLimiter | None = None, + timeout: float = 30.0, + client: Any = None, + session_client: Any = None, + ): + """`credentials` log in on the first call; `session_token` reuses a + login made elsewhere (an account with two-factor login needs that). + `commission` overrides the rate fee estimates use, which is otherwise + the account's own (`commission_type`).""" + import httpx + + if session is None: + if credentials is None and not session_token: + from .errors import CredentialsMissing + + raise CredentialsMissing(f"{VENUE}: set SMARKETS_USERNAME and SMARKETS_PASSWORD, or pass session_token=") + session = SmarketsSession( + username=credentials.username if credentials else None, + password=credentials.password if credentials else None, + session_token=session_token, client=session_client, timeout=timeout, base_url=url, + ) + self.credentials = credentials + self.session = session + self.account = Account(venue=VENUE, name=account_name) + # 1200 requests a minute an account; placing and cancelling 10 a second each. + self.limiter = limiter or BudgetLimiter(read_per_second=8, write_per_second=10) + self.url = url + self.commission = commission + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) + shared.headers["User-Agent"] = user_agent() + shared.headers["Accept"] = "application/json" + self.http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE) + # Cancel-all is not retried on a 429: the open orders are cancelled + # one by one instead. + self._cancel_all_http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE, attempts=1) + self._last_cancel_all: float | None = None + self._account_row: dict[str, Any] | None = None + + # -- plumbing ------------------------------------------------------------- + + async def _call( + self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, http: AsyncHttpClient | None = None, + ) -> Any: + """One call with the session token, paced; a 401 logs in again once.""" + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + query = {k: v for k, v in (params or {}).items() if v is not None and v != []} + for attempt in range(2): + token = await asyncio.to_thread(self.session.token) + try: + return await (http or self.http).request( + method, path, params=query or None, json=body, headers={"Authorization": f"Session-Token {token}"}, + ) + except AuthenticationError as exc: + if exc.status == 401 and attempt == 0: + self.session.invalidate() + continue + if exc.status == 403: + code = error_type(exc.body) + if code is None: + raise PermissionDenied(f"{VENUE}: refused by Smarkets' Cloudflare front (403)", + body=exc.body, status=403) from None + hint = ("; placing orders needs Smarkets' approved API access" if code == "FORBIDDEN" + and path.startswith("/v3/orders/") else "") + raise PermissionDenied(f"{VENUE}: refused: {code}{hint}", body=exc.body, status=403) from None + raise + raise AssertionError("unreachable") # pragma: no cover + + async def account_info(self) -> dict[str, Any]: + """The account (`GET /v3/accounts/`): its currency, balance, exposure + and commission rate. The latest read is kept for the currency and the + rate.""" + raw = await self._call("GET", "/v3/accounts/") or {} + self._account_row = dict(raw.get("account") or {}) + return self._account_row + + async def currency(self) -> str: + """The account's currency, read once: every quantity and amount is in it.""" + if self._account_row is None: + await self.account_info() + return str((self._account_row or {}).get("currency") or "GBP") + + async def commission_rate(self) -> float: + """The commission rate fee estimates use: `commission` if given, else + the account's `commission_type`, else the standard 2%.""" + if self.commission is not None: + return float(self.commission) + if self._account_row is None: + await self.account_info() + try: + return float((self._account_row or {}).get("commission_type") or DEFAULT_COMMISSION) + except ValueError: + return DEFAULT_COMMISSION + + # -- orders --------------------------------------------------------------- + + async def create_order(self, request: OrderRequest) -> Order: + """Place one order. An immediate one answers once it has matched what + it could; a resting one once the exchange accepted it, so a fill + straight after shows on `fetch_order`.""" + body = build_order(request) + try: + row = await self._call("POST", "/v3/orders/", body=body, kind="write") + except BadRequest as exc: + raise error_of(exc) from None + if not isinstance(row, dict) or not row.get("order_id"): + raise OrderRejected(f"{VENUE}: the order was answered without an id", info=row if isinstance(row, dict) else {}) + return order_after_placing(request, row, account=self.account) + + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: + """Many orders, one request each (Smarkets has no batch route), one + result each in the order asked for. A refused order comes back as its + exception.""" + out: list[Order | Exception] = [] + for request in requests: + try: + out.append(await self.create_order(request)) + except (ExchangeError, NetworkError) as exc: + out.append(exc) + return out + + async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: + """Cancel one order and read it back. One Smarkets refuses to cancel + (already matched, too late) is read back as it stands; a cancel the + exchange has not applied yet reads `pending_cancel`.""" + try: + await self._call("DELETE", f"/v3/orders/{order_id}/", kind="write", priority=Priority.HIGH) + except MarketNotFound as exc: + raise OrderNotFound(f"{VENUE}: no order {order_id}", body=exc.body, status=exc.status) from None + except BadRequest as exc: + order = await self.fetch_order(order_id) + if order.is_terminal: + return order + raise OrderRejected(f"{VENUE}: cancel of {order_id} refused: {error_type(exc.body) or exc}", + reason=error_type(exc.body) or "cancel_refused", body=exc.body) from None + order = await self.fetch_order(order_id) + if order.status == OrderStatus.OPEN: + return order.model_copy(update={"status": OrderStatus.PENDING_CANCEL}) + return order + + async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]: + """Cancel each order, one request each, one result each.""" + out: list[Order | Exception] = [] + for order_id in order_ids: + try: + out.append(await self.cancel_order(order_id)) + except (ExchangeError, NetworkError) as exc: + out.append(exc) + return out + + async def cancel_all_orders(self, *, market_id: str | None = None) -> int | None: + """Every open order, on a Smarkets market (`smarkets:`), on + one contract, or on the account. + + The account or a Smarkets market is one request, which Smarkets + acknowledges without a count (`None`). It allows one a minute: a + second inside the minute, a refused one (429), and a contract (which + cancel-all cannot name) cancel the open orders one by one, returning + how many were cancelled.""" + native = ids.native(VENUE, market_id) if market_id else None + whole_market = native is None or "-" not in native + recent = self._last_cancel_all is not None and time.monotonic() - self._last_cancel_all < CANCEL_ALL_INTERVAL_S + if whole_market and not recent: + self._last_cancel_all = time.monotonic() + try: + await self._call("DELETE", "/v3/orders/", params={"market_id": market_of(native) if native else None}, + kind="write", priority=Priority.HIGH, http=self._cancel_all_http) + return None + except RateLimitExceeded: + pass + orders = await self.fetch_open_orders(market_id=market_id) + results = await self.cancel_orders([o.id for o in orders]) + return sum(1 for r in results if isinstance(r, Order)) + + async def fetch_order(self, order_id: str) -> Order: + """One order by id, in any state Smarkets still lists.""" + raw = await self._call("GET", "/v3/orders/", params={"id": [str(order_id)]}) or {} + rows = [r for r in raw.get("orders") or [] if isinstance(r, dict) and str(r.get("id")) == str(order_id)] + if rows: + return order_of_row(rows[0], account=self.account) + try: + row = await self._call("GET", f"/v3/orders/{order_id}/") + except MarketNotFound as exc: + raise OrderNotFound(f"{VENUE}: no order {order_id}", body=exc.body, status=exc.status) from None + if not isinstance(row, dict) or not row.get("id"): + raise OrderNotFound(f"{VENUE}: no order {order_id}") + return order_of_row(row, account=self.account) + + def _scope(self, market_id: str | None) -> dict[str, Any]: + """Smarkets' filter for a contract (`-`) or a + Smarkets market (a bare market id, the Synpath event).""" + if not market_id: + return {} + native = ids.native(VENUE, market_id) + if "-" in native: + market, contract = parse_contract_id(native) + return {"market_id": [market], "contract_id": contract} + return {"market_id": [market_of(native)]} + + async def _orders(self, params: dict[str, Any], *, after: str | None = None, + pages: int = MAX_PAGES, limit: int = PER_PAGE) -> tuple[list[dict[str, Any]], str | None]: + """Order rows by id from `after`, page by page; and the id to resume + after, when more are left.""" + rows: list[dict[str, Any]] = [] + for _ in range(pages): + raw = await self._call("GET", "/v3/orders/", params={ + **params, "limit": limit, "sort": "id", "pagination_last_id": after, + "include_promotion_data": "false", + }) or {} + page = [r for r in raw.get("orders") or [] if isinstance(r, dict)] + rows += page + after = _next((raw.get("pagination") or {}).get("next_page"), "pagination_last_id") + if not page or not after: + return rows, None + return rows, after + + async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]: + """Every resting order, or every one on a contract or Smarkets market.""" + rows, _ = await self._orders({"state": list(OPEN_STATES), **self._scope(market_id)}) + orders = [order_of_row(row, account=self.account) for row in rows] + return [o for o in orders if o.status == OrderStatus.OPEN] + + async def fetch_orders( + self, *, status: str | None = None, market_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Order]: + """One page of the orders Smarkets lists (current and recently + settled), oldest first: `open`, `closed` (fully matched) or + `canceled` (ended with something unmatched). `since` keeps orders + created since then.""" + native = {"open": list(OPEN_STATES), "closed": ["filled", "settled"], "canceled": list(OPEN_STATES) + ["settled"]} + if status is not None and status not in native: + raise BadRequest(f"{VENUE}: status is open, closed or canceled") + if cursor is not None and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params: dict[str, Any] = {"state": native.get(status or ""), **self._scope(market_id)} + if since is not None: + from ..types import iso + + params["created_datetime_min"] = iso(since) + rows, following = await self._orders(params, after=cursor, pages=1, limit=min(limit or PER_PAGE, PER_PAGE)) + orders = [order_of_row(row, account=self.account) for row in rows] + wanted = {"open": OrderStatus.OPEN, "closed": OrderStatus.CLOSED, "canceled": OrderStatus.CANCELED}.get(status or "") + if wanted is not None: + orders = [o for o in orders if o.status == wanted] + return Page(orders, next_cursor=following) + + async def fetch_my_trades( + self, *, market_id: str | None = None, order_id: str | None = None, + since: int | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Fill]: + """The account's executions from its statement (`order.execute` + rows), newest first.""" + params: dict[str, Any] = {"source": ["order.execute"], "sort": "-seq,-subseq", + "limit": min(limit or ACTIVITY_PER_PAGE, ACTIVITY_PER_PAGE), + "include_promotion_data": "false"} + wanted_contract = None + if market_id: + native = ids.native(VENUE, market_id) + params["market_id"] = [market_of(native)] + if "-" in native: + wanted_contract = parse_contract_id(native)[1] + if order_id: + params["order_id"] = [str(order_id)] + if since is not None: + from ..types import iso + + params["timestamp_min"] = iso(since) + if cursor: + seq, sep, subseq = cursor.partition(":") + if not sep or not seq.isdigit() or not subseq.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params["pagination_last_seq"], params["pagination_last_subseq"] = seq, subseq + raw = await self._call("GET", "/v3/accounts/activity/", params=params) or {} + rows = [r for r in raw.get("account_activity") or [] if isinstance(r, dict) + and r.get("source") == "order.execute" and r.get("contract_id")] + if wanted_contract: + rows = [r for r in rows if str(r.get("contract_id")) == wanted_contract] + fills = [fill_of_activity(row, account=self.account) for row in rows] + currency = await self.currency() + fills = [f.model_copy(update={"fee_currency": currency}) if f.fee is not None else f for f in fills] + next_page = (raw.get("pagination") or {}).get("next_page") + seq = _next(next_page, "pagination_last_seq") + subseq = _next(next_page, "pagination_last_subseq") + last = raw.get("account_activity") or [] + if next_page and (seq is None or subseq is None) and last: + seq, subseq = str(last[-1].get("seq")), str(last[-1].get("subseq")) + return Page(fills, next_cursor=f"{seq}:{subseq}" if next_page and seq and subseq else None) + + # -- account -------------------------------------------------------------- + + async def fetch_balance(self, *, account: Account | None = None) -> Balance: + """Cash (`balance`), what bets hold (`exposure`, `locked`), and what + is free to bet (`available_balance`).""" + row = await self.account_info() + available = D(row.get("available_balance")) + return Balance( + venue=VENUE, account=account or self.account, currency=str(row.get("currency") or "GBP"), + total=D(row.get("balance")), available=available, locked=abs(D(row.get("exposure"))), + buying_power=available, timestamp=int(time.time() * 1000), info=row, + ) + + async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: + """The account's matched, unsettled bets netted per contract on the + YES leg. `market_id` is a contract, `event_id` a Smarkets market.""" + rows, _ = await self._orders({"state": list(CURRENT_STATES), **self._scope(market_id or event_id)}) + positions = positions_of_orders(rows, account=self.account) + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) + positions = [p for p in positions if p.market_id == wanted] + return positions + + async def fetch_settlements( + self, *, market_id: str | None = None, since: int | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Settlement]: + """One page of recently settled orders, one settlement per contract + and side. `since` keeps orders created since then.""" + if cursor is not None and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params: dict[str, Any] = {"state": ["settled"], **self._scope(market_id)} + if since is not None: + from ..types import iso + + params["created_datetime_min"] = iso(since) + rows, following = await self._orders(params, after=cursor, pages=1) + settlements = settlements_of_orders(rows, account=self.account) + return Page(settlements[:limit] if limit else settlements, next_cursor=following) + + async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> FeeEstimate: + """The commission on `quantity` contracts at the YES `price` if the bet + wins: a share of its profit. A `sell` lays the contract, which wins + `price` a contract when it loses. Makers pay the same.""" + native = ids.native(VENUE, market_id) + schedule = fee_schedule_of(market_of(native), await self.commission_rate()) + paid = D(price) if side == Side.BUY else ONE - D(price) + fee = schedule.estimate(float(paid), float(quantity), taker=True) + return FeeEstimate( + venue=VENUE, market_id=ids.qualify(VENUE, native), side=side, price=D(price), quantity=D(quantity), + taker_fee=D(fee) if fee is not None else None, maker_fee=D(fee) if fee is not None else None, + currency=await self.currency(), + info={"schedule": schedule.model_dump(), "charged": "at settlement, on net winnings in the Smarkets market"}, + ) + + async def close(self) -> None: + await self.http.close() + self.session.close() + + +__all__ = [ + "SmarketsTrading", "PRICE_LADDER", "build_order", "price_bps", "quantity_units", "order_of_row", + "order_after_placing", "fill_of_activity", "positions_of_orders", "settlements_of_orders", +] diff --git a/tests/samples/smarkets_trading.json b/tests/samples/smarkets_trading.json new file mode 100644 index 0000000..be4db3a --- /dev/null +++ b/tests/samples/smarkets_trading.json @@ -0,0 +1,235 @@ +{ + "login": { + "created_social_member": true, + "factor": "complete", + "stop": "2018-01-24T19:49:43.964757", + "token": "WzM2NjI1MDIsMjIyMDIxMzJd.jKWaw5j3XtYX6huY7HZVEvISu6k", + "verify": false + }, + "create": { + "available_balance": "100.00", + "available_quantity": 15000, + "contract_id": "123456", + "executed_avg_price": 5000, + "exposure": "-50.00", + "market_id": "128939", + "order_id": "1518455672861", + "orig_price": 5000, + "price": 5000, + "quantity": 10000, + "side": "buy", + "total_executed_quantity": 15000 + }, + "order": { + "average_price_matched": 5000, + "average_price_matched_precise": "5000", + "best_price": { + "price": 5000, + "source": "bet365" + }, + "bonus_bet": false, + "contract_id": "123456", + "created_datetime": "2012-08-14T00:56:45.291688Z", + "id": "1518455672861", + "label": "strategy_1", + "last_modified_datetime": "2012-08-14T00:56:45.291688Z", + "market_id": "128939", + "original_order_id": "1518455672861", + "otc_available": false, + "outcome": "winner", + "price": 5000, + "quantity": 10000, + "quantity_filled": 10000, + "quantity_filled_pending": 2500, + "quantity_filled_pending_user_currency": 2500, + "quantity_filled_user_currency": 10000, + "quantity_traded_out_user_currency": 10000, + "quantity_unfilled": 2500, + "quantity_unfilled_user_currency": 2500, + "quantity_user_currency": 10000, + "sequence_number": 94115, + "side": "buy", + "stake_traded_out_user_currency": "24.08", + "state": "created", + "token_used": { + "bet_token_type": "free_bet", + "boost_percent": 1.0, + "original_price": 5000 + }, + "type": "good_til_halted" + }, + "orders": { + "orders": [ + { + "average_price_matched": 5000, + "average_price_matched_precise": "5000", + "best_price": { + "price": 5000, + "source": "bet365" + }, + "bonus_bet": false, + "contract_id": "123456", + "created_datetime": "2012-08-14T00:56:45.291688Z", + "id": "1518455672861", + "label": "strategy_1", + "last_modified_datetime": "2012-08-14T00:56:45.291688Z", + "market_id": "128939", + "original_order_id": "1518455672861", + "otc_available": false, + "outcome": "winner", + "price": 5000, + "quantity": 10000, + "quantity_filled": 10000, + "quantity_filled_pending": 2500, + "quantity_filled_pending_user_currency": 2500, + "quantity_filled_user_currency": 10000, + "quantity_traded_out_user_currency": 10000, + "quantity_unfilled": 2500, + "quantity_unfilled_user_currency": 2500, + "quantity_user_currency": 10000, + "sequence_number": 94115, + "side": "buy", + "stake_traded_out_user_currency": "24.08", + "state": "created", + "token_used": { + "bet_token_type": "free_bet", + "boost_percent": 1.0, + "original_price": 5000 + }, + "type": "good_til_halted" + } + ], + "otc_available": false, + "pagination": { + "next_page": "?limit=20&pagination_last_id=810214202189041" + } + }, + "account": { + "account": { + "account_id": "123556546", + "available_balance": "100.00", + "balance": "150.00", + "bonus_balance": "10.00", + "commission_type": "0.02", + "currency": "GBP", + "exposure": "-50.00", + "is_first_time_deposit": true, + "signup_date": "2021-01-01T00:00:00.000000" + } + }, + "activity": { + "account_activity": [ + { + "amount": "-19.00", + "bet_token_type": "free_bet", + "bonus_bet": false, + "bonus_change": "10.00", + "commission": "2.20", + "contract_id": "123456", + "event_id": "565413", + "exposure": "-50.00", + "extra": "market_halted", + "label": "strategy_1", + "market_id": "128939", + "money": "483.56", + "money_change": "2.20", + "order_id": "1518455672861", + "price": 5000, + "quantity": 10000, + "quantity_change": -290023, + "quantity_user_currency": 10000, + "quantity_user_currency_change": -2819, + "seq": 11423, + "side": "buy", + "source": "admin.credit", + "subseq": 123242, + "timestamp": "2017-02-01T10:23:54.361612" + } + ], + "contracts": [ + { + "name": "Arsenal", + "state_or_outcome": "live" + } + ], + "events": [ + { + "full_slug": "/sport/horse-racing/ascot/2017/12/22/15:40", + "name": "15:40", + "parent_id": "565413", + "slug": "ascot-2017-12-22T00:00:00-15-40", + "state": "new", + "type": "horse_racing_race" + } + ], + "markets": [ + { + "contract_selections": [ + 254245, + 4565452, + 7894545 + ], + "market_type": { + "name": "OVER_UNDER", + "param": "2.5", + "params": { + "HANDICAP": "1.5", + "TOTAL": "151.5" + } + }, + "name": "To win" + } + ], + "pagination": { + "next_page": "?limit=20&sort=seq,subseq&pagination_last_seq=20&pagination_last_seq=3" + } + }, + "cancel": {}, + "cancel_all": {}, + "errors": { + "ORDER_REJECTED_INSUFFICIENT_FUNDS": { + "data": null, + "error_type": "ORDER_REJECTED_INSUFFICIENT_FUNDS" + }, + "ORDER_REJECTED_MARKET_HALTED": { + "data": null, + "error_type": "ORDER_REJECTED_MARKET_HALTED" + }, + "ORDER_INVALID_INVALID_PRICE": { + "data": null, + "error_type": "ORDER_INVALID_INVALID_PRICE" + }, + "ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY": { + "data": null, + "error_type": "ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY" + }, + "ORDER_REJECTED_CROSSED_SELF": { + "data": null, + "error_type": "ORDER_REJECTED_CROSSED_SELF" + }, + "FORBIDDEN": { + "data": null, + "error_type": "FORBIDDEN" + }, + "AUTH_REQUIRED": { + "data": null, + "error_type": "AUTH_REQUIRED" + }, + "ORDER_NOT_FOUND": { + "data": null, + "error_type": "ORDER_NOT_FOUND" + }, + "ORDER_CANCEL_REJECTED_NOT_FOUND": { + "data": null, + "error_type": "ORDER_CANCEL_REJECTED_NOT_FOUND" + }, + "ORDER_CANCEL_REJECTED_TOO_LATE": { + "data": null, + "error_type": "ORDER_CANCEL_REJECTED_TOO_LATE" + }, + "RATE_LIMIT_EXCEEDED": { + "data": null, + "error_type": "RATE_LIMIT_EXCEEDED" + } + } +} diff --git a/tests/test_smarkets.py b/tests/test_smarkets.py index 9ad683e..6d6dce9 100644 --- a/tests/test_smarkets.py +++ b/tests/test_smarkets.py @@ -507,9 +507,9 @@ def test_declared_honestly(self): assert has["fetch_markets"] is True and has["fetch_order_books"] is True and has["search"] is True assert has["fetch_trades"] == "partial" and has["fetch_ohlcv"] is False and has["sort"] is False - def test_order_entry_is_not_offered(self): - with pytest.raises(NotSupported, match="market data only"): - synpath.Client().trading("smarkets") + def test_order_entry_needs_the_login(self): + with pytest.raises(CredentialsMissing, match="SMARKETS_USERNAME"): + synpath.Client({"smarkets": None}).trading("smarkets") def test_venue_rules_note_the_minimum_stake(self): from decimal import Decimal diff --git a/tests/test_smarkets_trading.py b/tests/test_smarkets_trading.py new file mode 100644 index 0000000..0f25240 --- /dev/null +++ b/tests/test_smarkets_trading.py @@ -0,0 +1,528 @@ +"""Smarkets order entry: orders built from requests, Smarkets' records read on +the YES leg, and the adapter against a scripted venue. + +Records in `tests/samples/smarkets_trading.json` are assembled from the +field examples of Smarkets' trading API reference (the OpenAPI document +behind docs.smarkets.com), since betting needs an approved API account: each +response is its schema with every field at its documented example. The login +example lost its NemID fields (`nemid_session_id`, `redirect_url`) and has +`verify` false; the error bodies are the documented `{"data", "error_type"}` +shape with documented codes. Tests change single fields where a scenario +needs consistent numbers. Every request goes to an `httpx.MockTransport`.""" +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +import httpx +import pytest + +from synpath.trading.credentials import SmarketsCredentials +from synpath.trading.errors import ( + CredentialsMissing, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, PermissionDenied, +) +from synpath.trading.smarkets import ( + PRICE_LADDER, SmarketsTrading, build_order, fill_of_activity, order_after_placing, order_of_row, + positions_of_orders, price_bps, quantity_units, settlements_of_orders, +) +from synpath.trading.types import OrderRequest, OrderStatus, OrderType, PositionSide, Side, TimeInForce + +from conftest import load + +S = load("smarkets_trading.json") +CONTRACT = "smarkets:128939-123456" +ORDER_ID = "1518455672861" + + +def request(**changes) -> OrderRequest: + base = {"market_id": CONTRACT, "side": Side.BUY, "quantity": Decimal("10"), "price": Decimal("0.5")} + return OrderRequest(**{**base, **changes}) + + +def row(base: dict, **changes) -> dict: + return {**base, **changes} + + +def resting(**changes) -> dict: + """The documented order as a consistent unmatched one: 10 contracts, none matched.""" + return row(S["order"], **{ + "state": "created", "quantity": 100000, "quantity_user_currency": 100000, "quantity_filled": 0, + "quantity_filled_user_currency": 0, "quantity_filled_pending": 0, "quantity_filled_pending_user_currency": 0, + "quantity_unfilled": 100000, "quantity_unfilled_user_currency": 100000, "outcome": None, **changes, + }) + + +class Venue: + """Answers requests by `METHOD /path`, and records them.""" + + def __init__(self, **answers): + self.answers = {"POST /v3/sessions/": httpx.Response(201, json=S["login"]), **answers} + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + answer = self.answers.get(f"{req.method} {req.url.path}") + if answer is None: + return httpx.Response(404, json={"data": None, "error_type": "NOT_FOUND"}) + if callable(answer): + answer = answer(req) + if isinstance(answer, httpx.Response): + return answer + return httpx.Response(200, json=answer) + + def sent(self, method: str, path: str) -> list[httpx.Request]: + return [r for r in self.requests if r.method == method and r.url.path == path] + + +def adapter(venue: Venue, **kwargs) -> SmarketsTrading: + creds = SmarketsCredentials(username="john.doe@company.com", password="verysecurepassword") + return SmarketsTrading( + creds, client=httpx.AsyncClient(transport=httpx.MockTransport(venue)), + session_client=httpx.Client(transport=httpx.MockTransport(venue)), **kwargs, + ) + + +def run(coro): + return asyncio.run(coro) + + +def error(code: str, status: int = 400) -> httpx.Response: + return httpx.Response(status, json=S["errors"][code]) + + +class TestOrders: + def test_a_buy_backs_the_contract_at_its_price_in_basis_points(self): + assert build_order(request(client_order_id="c-1")) == { + "market_id": "128939", "contract_id": "123456", "side": "buy", "price": 5000, "quantity": 100000, + "type": "good_til_halted", "label": "c-1", + } + + def test_a_sell_lays_it(self): + body = build_order(request(side=Side.SELL, price=Decimal("0.4"))) + assert body["side"] == "sell" and body["price"] == 4000 + + def test_the_ladder_is_betfairs_odds_in_basis_points_with_the_ends(self): + assert PRICE_LADDER[0] == 1 and PRICE_LADDER[-1] == 9999 + # Odds 2.0, 2.1, 2.2; and 32 and 6.4, whose half basis points round to even. + assert {5000, 4762, 4545, 312, 1562}.issubset(PRICE_LADDER) + # Every price in the recorded public quotes is on it. + quotes = load("smarkets_quotes.json") + seen = {level["price"] for contract in quotes.values() for side in ("bids", "offers") + for level in contract.get(side) or []} + assert seen and seen <= set(PRICE_LADDER) + + def test_prices_snap_to_the_ladder_on_the_cheaper_side(self): + # 1/2.1 is the tick 4762 for either side, within half a basis point. + assert price_bps(Side.BUY, Decimal("0.476190")) == price_bps(Side.SELL, Decimal("0.476190")) == 4762 + # 0.475 lies between 4717 (odds 2.12) and 4762 (odds 2.1): a buy pays no more, a sell receives no less. + assert price_bps(Side.BUY, Decimal("0.475")) == 4717 + assert price_bps(Side.SELL, Decimal("0.475")) == 4762 + assert price_bps(Side.BUY, Decimal("0.0001")) == 1 and price_bps(Side.SELL, Decimal("0.9999")) == 9999 + + def test_prices_outside_the_book_are_refused(self): + for bad in (Decimal("0"), Decimal("1"), Decimal("1.2")): + with pytest.raises(InvalidOrder): + price_bps(Side.BUY, bad) + with pytest.raises(InvalidOrder): + price_bps(Side.BUY, Decimal("0.00004")) + with pytest.raises(InvalidOrder): + price_bps(Side.SELL, Decimal("0.99996")) + + def test_quantities_are_pots_in_ten_thousandths_rounded_down(self): + assert quantity_units(Decimal("12.34567")) == 123456 + with pytest.raises(InvalidOrder): + quantity_units(Decimal("0.00001")) + with pytest.raises(InvalidOrder): + quantity_units(Decimal("500000")) + + def test_order_types(self): + assert build_order(request(params={"keep_in_play": True}))["type"] == "keep_in_play" + assert build_order(request(params={"order_type": "good_til_cancelled"}))["type"] == "good_til_cancelled" + ioc = build_order(request(time_in_force=TimeInForce.IOC, params={"minimum_quantity": "4"})) + assert ioc["type"] == "immediate_or_cancel" and ioc["minimum_accepted_quantity"] == 40000 + market = build_order(request(type=OrderType.MARKET)) + assert market["type"] == "immediate_or_cancel" and "minimum_accepted_quantity" not in market + fok = build_order(request(time_in_force=TimeInForce.FOK)) + assert fok["type"] == "immediate_or_cancel" and fok["minimum_accepted_quantity"] == 100000 + + def test_a_label_in_params_wins(self): + assert build_order(request(client_order_id="c-1", params={"label": "strategy_1"}))["label"] == "strategy_1" + assert "label" not in build_order(request()) + + @pytest.mark.parametrize("changes", [ + {"time_in_force": TimeInForce.GTD, "expires_at": 1}, {"time_in_force": TimeInForce.DAY}, + {"post_only": True}, {"reduce_only": True}, {"type": OrderType.ICEBERG}, + {"type": OrderType.MARKET, "price": None}, {"market_id": "smarkets:128939"}, + {"params": {"order_type": "immediate_or_cancel"}}, + ]) + def test_what_smarkets_does_not_hold_is_refused(self, changes): + from synpath.errors import BadRequest + + with pytest.raises(BadRequest): # InvalidOrder is a BadRequest + build_order(request(**changes)) + + +class TestRecords: + def test_the_documented_order(self): + order = order_of_row(S["order"]) + assert order.id == ORDER_ID and order.market_id == CONTRACT and order.side == Side.BUY + assert order.client_order_id == "strategy_1" and order.price == Decimal("0.5") + assert order.quantity == Decimal("1") and order.created_at == 1344905805291 + # Filled 10000 and unfilled 2500 do not add up with the pending 2500, so + # the pending part is taken as already inside `quantity_filled`. + assert order.filled == Decimal("1") and order.average_price == Decimal("0.5") + + def test_an_unmatched_order_rests(self): + order = order_of_row(resting()) + assert order.status == OrderStatus.OPEN and order.filled == 0 and order.remaining == Decimal("10") + assert order.time_in_force == TimeInForce.GTC and order.average_price is None + + def test_pending_matches_count_as_filled_when_smarkets_counts_them_apart(self): + order = order_of_row(resting(state="partial", quantity_filled_user_currency=20000, + quantity_filled_pending_user_currency=30000, + quantity_unfilled_user_currency=50000)) + assert order.status == OrderStatus.OPEN and order.filled == Decimal("5") and order.remaining == Decimal("5") + + def test_nothing_left_unmatched_is_cancelled_or_closed(self): + cancelled = order_of_row(resting(quantity_unfilled_user_currency=0)) + assert cancelled.status == OrderStatus.CANCELED and cancelled.remaining == 0 + partly = order_of_row(resting(state="partial", quantity_filled_user_currency=40000, + quantity_unfilled_user_currency=0)) + assert partly.status == OrderStatus.CANCELED and partly.filled == Decimal("4") + filled = order_of_row(resting(state="filled", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0)) + assert filled.status == OrderStatus.CLOSED and filled.filled == filled.quantity + + def test_an_immediate_order_is_ioc(self): + assert order_of_row(resting(type="immediate_or_cancel")).time_in_force == TimeInForce.IOC + + def test_a_lay_is_a_sell(self): + assert order_of_row(resting(side="sell")).side == Side.SELL + + def test_a_placement(self): + placed = order_after_placing(request(client_order_id="c-1"), row(S["create"], quantity=100000, + total_executed_quantity=0, available_quantity=100000, executed_avg_price=None), + account=None) # type: ignore[arg-type] + assert placed.id == ORDER_ID and placed.status == OrderStatus.OPEN and placed.remaining == Decimal("10") + assert placed.client_order_id == "c-1" and placed.market_id == CONTRACT + + def test_a_placement_that_matched_in_full_is_closed(self): + placed = order_after_placing(request(), S["create"], account=None) # type: ignore[arg-type] + assert placed.status == OrderStatus.CLOSED and placed.remaining == 0 and placed.average_price == Decimal("0.5") + + def test_an_ioc_placement_is_cancelled_after_what_matched(self): + placed = order_after_placing(request(time_in_force=TimeInForce.IOC), + row(S["create"], quantity=100000, total_executed_quantity=30000, + available_quantity=70000), account=None) # type: ignore[arg-type] + assert placed.status == OrderStatus.CANCELED and placed.filled == Decimal("3") and placed.remaining == 0 + + def test_an_execution_is_a_fill(self): + fill = fill_of_activity(row(S["activity"]["account_activity"][0], source="order.execute")) + assert fill.id == "11423-123242" and fill.order_id == ORDER_ID and fill.market_id == CONTRACT + assert fill.price == Decimal("0.5") and fill.quantity == Decimal("1") and fill.side == Side.BUY + assert fill.fee == Decimal("2.20") and fill.timestamp == 1485944634361 + + def test_positions_net_backs_and_lays_per_contract(self): + back = resting(state="filled", quantity_filled_user_currency=100000, quantity_unfilled_user_currency=0, + average_price_matched_precise="4000") + lay = resting(id="2", side="sell", state="partial", quantity_filled_user_currency=40000, + quantity_unfilled_user_currency=60000, average_price_matched_precise="5000") + settled = resting(id="3", state="settled", quantity_filled_user_currency=100000) + [position] = positions_of_orders([back, lay, settled]) + assert position.market_id == CONTRACT and position.side == PositionSide.LONG + assert position.contracts == Decimal("6") and position.inventory_yes == 10 and position.inventory_no == 4 + # 10 at 0.40 and 4 at 0.50 on the YES leg. + assert position.entry_price == (Decimal("6") / Decimal("14")).quantize(Decimal("1e-10")) + # Paid 4.00 backing and 2.00 laying; if the contract loses, the lays return 4. + assert position.margin == Decimal("2") + + def test_settlements_read_the_outcome_on_the_yes_leg(self): + won = resting(state="settled", outcome="winner", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0, average_price_matched_precise="4000") + lay = resting(id="2", side="sell", state="settled", outcome="winner", quantity_filled_user_currency=50000, + quantity_unfilled_user_currency=0, average_price_matched_precise="4000") + back, laid = settlements_of_orders([won, lay, resting()]) + assert back.held == PositionSide.LONG and back.result == "yes" and back.won is True + assert back.quantity == 10 and back.cost == Decimal("4") and back.payout == 10 and back.pnl == Decimal("6") + assert laid.held == PositionSide.SHORT and laid.won is False and laid.cost == Decimal("3") and laid.pnl == -3 + void = settlements_of_orders([row(won, outcome="void")])[0] + assert void.result == "void" and void.pnl == 0 + + +class TestAdapter: + def test_login_then_the_session_token_on_every_call(self): + venue = Venue(**{"POST /v3/orders/": row(S["create"], quantity=100000, total_executed_quantity=0, + available_quantity=100000)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_order(request(client_order_id="c-1")) + + order = run(go()) + login, submit = venue.requests + assert json.loads(login.content) == {"username": "john.doe@company.com", "password": "verysecurepassword"} + assert submit.headers["Authorization"] == f"Session-Token {S['login']['token']}" + assert json.loads(submit.content)["label"] == "c-1" + assert order.status == OrderStatus.OPEN and order.client_order_id == "c-1" + + def test_an_expired_session_logs_in_again_once(self): + answers = iter([error("AUTH_REQUIRED", 401), httpx.Response(200, json=S["account"])]) + venue = Venue(**{"GET /v3/accounts/": lambda req: next(answers)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_balance() + + balance = run(go()) + assert len(venue.sent("POST", "/v3/sessions/")) == 2 + assert (balance.total, balance.available, balance.locked) == (Decimal("150.00"), Decimal("100.00"), Decimal("50.00")) + assert balance.currency == "GBP" + + def test_an_account_without_api_access_is_refused_with_a_reason(self): + venue = Venue(**{"POST /v3/orders/": error("FORBIDDEN", 403)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_order(request()) + + with pytest.raises(PermissionDenied, match="approved API access"): + run(go()) + + @pytest.mark.parametrize("code, kind", [ + ("ORDER_REJECTED_INSUFFICIENT_FUNDS", InsufficientFunds), ("ORDER_REJECTED_MARKET_HALTED", MarketHalted), + ("ORDER_INVALID_INVALID_PRICE", InvalidOrder), ("ORDER_REJECTED_CROSSED_SELF", OrderRejected), + ("ORDER_CANCELLED_INSUFFICIENT_LIQUIDITY", OrderRejected), + ]) + def test_a_refused_order_is_typed(self, code, kind): + venue = Venue(**{"POST /v3/orders/": error(code)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_order(request()) + + with pytest.raises(kind) as caught: + run(go()) + assert code in str(caught.value) + + def test_create_orders_returns_one_result_each(self): + answers = iter([httpx.Response(200, json=S["create"]), error("ORDER_REJECTED_INSUFFICIENT_FUNDS")]) + venue = Venue(**{"POST /v3/orders/": lambda req: next(answers)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.create_orders([request(), request(), request(post_only=True)]) + + placed, refused, invalid = run(go()) + assert placed.status == OrderStatus.CLOSED + assert isinstance(refused, InsufficientFunds) and isinstance(invalid, InvalidOrder) + assert len(venue.sent("POST", "/v3/orders/")) == 2 + + def test_cancel_reads_the_order_back(self): + listed = iter([{"orders": [resting(quantity_unfilled_user_currency=0)]}]) + venue = Venue(**{f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], "GET /v3/orders/": lambda req: next(listed)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_order(ORDER_ID) + + order = run(go()) + assert order.status == OrderStatus.CANCELED + assert venue.sent("GET", "/v3/orders/")[0].url.params.get_list("id") == [ORDER_ID] + + def test_a_cancel_not_applied_yet_is_pending(self): + venue = Venue(**{f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], "GET /v3/orders/": {"orders": [resting()]}}) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_order(ORDER_ID) + + assert run(go()).status == OrderStatus.PENDING_CANCEL + + def test_a_refused_cancel_reads_back_and_an_unknown_one_is_not_found(self): + filled = resting(state="filled", quantity_filled_user_currency=100000, quantity_unfilled_user_currency=0) + venue = Venue(**{ + f"DELETE /v3/orders/{ORDER_ID}/": error("ORDER_CANCEL_REJECTED_TOO_LATE"), + "GET /v3/orders/": {"orders": [filled]}, + "DELETE /v3/orders/9/": error("ORDER_CANCEL_REJECTED_NOT_FOUND", 404), + }) + + async def go(): + async with adapter(venue) as smk: + too_late = await smk.cancel_order(ORDER_ID) + with pytest.raises(OrderNotFound): + await smk.cancel_order("9") + return too_late + + assert run(go()).status == OrderStatus.CLOSED + + def test_cancel_all_is_one_request_once_a_minute_then_order_by_order(self): + venue = Venue(**{ + "DELETE /v3/orders/": S["cancel_all"], + "GET /v3/orders/": lambda req: {"orders": [resting()] if req.url.params.get_list("state") else + [resting(quantity_unfilled_user_currency=0)]}, + f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], + }) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_all_orders(market_id="smarkets:128939"), await smk.cancel_all_orders() + + assert run(go()) == (None, 1) + [everything] = venue.sent("DELETE", "/v3/orders/") + assert everything.url.params["market_id"] == "128939" + assert len(venue.sent("DELETE", f"/v3/orders/{ORDER_ID}/")) == 1 + + def test_cancel_all_on_a_contract_and_after_a_429_goes_order_by_order(self): + venue = Venue(**{ + "DELETE /v3/orders/": error("RATE_LIMIT_EXCEEDED", 429), + "GET /v3/orders/": lambda req: {"orders": [resting()] if req.url.params.get_list("state") else + [resting(quantity_unfilled_user_currency=0)]}, + f"DELETE /v3/orders/{ORDER_ID}/": S["cancel"], + }) + + async def go(): + async with adapter(venue) as smk: + return await smk.cancel_all_orders(), await smk.cancel_all_orders(market_id=CONTRACT) + + assert run(go()) == (1, 1) + assert len(venue.sent("DELETE", "/v3/orders/")) == 1 # not retried + on_contract = [r for r in venue.sent("GET", "/v3/orders/") if r.url.params.get("contract_id")] + assert on_contract and on_contract[0].url.params["contract_id"] == "123456" + + def test_open_orders_page_by_id(self): + pages = iter([{"orders": [resting()], "pagination": {"next_page": "?limit=100&pagination_last_id=1518455672861"}}, + {"orders": [resting(id="1518455672862"), resting(id="3", quantity_unfilled_user_currency=0)], + "pagination": {"next_page": None}}]) + venue = Venue(**{"GET /v3/orders/": lambda req: next(pages)}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_open_orders(market_id=CONTRACT) + + orders = run(go()) + assert [o.id for o in orders] == [ORDER_ID, "1518455672862"] + first, second = venue.sent("GET", "/v3/orders/") + assert first.url.params.get_list("state") == ["created", "partial"] + assert first.url.params["market_id"] == "128939" and first.url.params["contract_id"] == "123456" + assert second.url.params["pagination_last_id"] == ORDER_ID + + def test_fetch_orders_by_status_with_a_cursor(self): + venue = Venue(**{"GET /v3/orders/": {**S["orders"], "orders": [resting(state="filled", + quantity_filled_user_currency=100000, quantity_unfilled_user_currency=0)]}}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_orders(status="closed", limit=5) + + page = run(go()) + assert [o.status for o in page] == [OrderStatus.CLOSED] and page.next_cursor == "810214202189041" + sent = venue.sent("GET", "/v3/orders/")[0] + assert sent.url.params.get_list("state") == ["filled", "settled"] and sent.url.params["limit"] == "5" + + def test_an_order_not_listed_is_read_by_id_or_not_found(self): + venue = Venue(**{"GET /v3/orders/": {"orders": []}, f"GET /v3/orders/{ORDER_ID}/": S["order"], + "GET /v3/orders/9/": error("ORDER_NOT_FOUND", 404)}) + + async def go(): + async with adapter(venue) as smk: + found = await smk.fetch_order(ORDER_ID) + with pytest.raises(OrderNotFound): + await smk.fetch_order("9") + return found + + assert run(go()).id == ORDER_ID + + def test_my_trades_are_the_statements_executions(self): + execution = row(S["activity"]["account_activity"][0], source="order.execute") + other = row(execution, seq=1, contract_id="999") + venue = Venue(**{ + "GET /v3/accounts/activity/": {**S["activity"], "account_activity": [execution, other], + "pagination": {"next_page": "?limit=500&pagination_last_seq=1&pagination_last_subseq=123242"}}, + "GET /v3/accounts/": S["account"], + }) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_my_trades(market_id=CONTRACT) + + fills = run(go()) + assert [f.id for f in fills] == ["11423-123242"] and fills[0].fee_currency == "GBP" + assert fills.next_cursor == "1:123242" + sent = venue.sent("GET", "/v3/accounts/activity/")[0] + assert sent.url.params.get_list("source") == ["order.execute"] and sent.url.params["market_id"] == "128939" + + def test_positions_and_settlements_read_the_orders(self): + def orders(req): + states = req.url.params.get_list("state") + if states == ["settled"]: + return {"orders": [resting(state="settled", outcome="loser", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0)]} + return {"orders": [resting(state="filled", quantity_filled_user_currency=100000, + quantity_unfilled_user_currency=0)]} + + venue = Venue(**{"GET /v3/orders/": orders}) + + async def go(): + async with adapter(venue) as smk: + return await smk.fetch_positions(market_id=CONTRACT), await smk.fetch_settlements() + + [position], settlements = run(go()) + assert position.contracts == Decimal("10") and position.side == PositionSide.LONG + assert settlements[0].result == "no" and settlements[0].won is False and settlements[0].pnl == Decimal("-5") + current = venue.sent("GET", "/v3/orders/")[0] + assert current.url.params.get_list("state") == ["created", "partial", "filled"] + + def test_the_fee_is_the_accounts_commission_on_what_the_bet_wins(self): + venue = Venue(**{"GET /v3/accounts/": {"account": {**S["account"]["account"], "commission_type": "0.03"}}}) + + async def go(): + async with adapter(venue) as smk: + return (await smk.fetch_fee_estimate(CONTRACT, Side.BUY, Decimal("0.4"), Decimal("100")), + await smk.fetch_fee_estimate(CONTRACT, Side.SELL, Decimal("0.4"), Decimal("100"))) + + buy, sell = run(go()) + assert buy.taker_fee == Decimal("1.8") and sell.taker_fee == Decimal("1.2") and buy.maker_fee == buy.taker_fee + assert buy.currency == "GBP" and len(venue.sent("GET", "/v3/accounts/")) == 1 + + +class TestWiring: + def test_credentials_are_needed(self): + with pytest.raises(CredentialsMissing): + SmarketsTrading(None) + + def test_the_client_the_engine_and_serve_build_it(self): + import synpath + from synpath.engine.__main__ import ADAPTERS + from synpath.server.serve import VENUES + + creds = SmarketsCredentials(username="u", password="p") + trading = synpath.Client({"smarkets": creds}).trading("smarkets") + try: + assert isinstance(trading, SmarketsTrading) and trading.has["edit_order"] is False + finally: + run(trading.close()) + assert ADAPTERS["smarkets"] == ("..trading.smarkets", "SmarketsTrading") + assert "smarkets" in VENUES and synpath.SmarketsTrading is SmarketsTrading + + def test_the_engine_streams_share_the_adapters_session(self): + from synpath.engine.feeds import default_streams + + creds = SmarketsCredentials(username="u", password="p") + trading = SmarketsTrading(creds) + try: + streams = default_streams({"smarkets": trading}, {"smarkets": creds})["smarkets"] + assert streams.private is None and streams.market.session is trading.session + finally: + run(trading.close()) + + def test_doctor_names_the_login(self, tmp_path, monkeypatch, capsys): + from synpath.trading.__main__ import doctor + + for name in ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"): + monkeypatch.delenv(name, raising=False) + env = tmp_path / ".env" + env.write_text("SMARKETS_USERNAME=jbloggs\nSMARKETS_PASSWORD=secret-pw-123\n") + assert doctor(str(env)) == 0 + out = capsys.readouterr().out + assert "user jbloggs" in out and "secret-pw-123" not in out From 29e4b4b6e1822346f045bc75191c9187e43f9c49 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 3/8] feat: myriad-amm Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/api.md | 4 +- docs/capabilities.md | 8 +- docs/trading.md | 40 +- synpath/trading/credentials.py | 2 +- synpath/trading/myriad.py | 676 +++++++++++++++++- synpath/trading/myriad_signing.py | 115 ++- tests/samples/myriad_quote_abstract_buy.json | 1 + tests/samples/myriad_quote_abstract_sell.json | 1 + tests/test_myriad_amm_trading.py | 531 ++++++++++++++ tests/test_myriad_trading.py | 20 +- 11 files changed, 1351 insertions(+), 51 deletions(-) create mode 100644 tests/samples/myriad_quote_abstract_buy.json create mode 100644 tests/samples/myriad_quote_abstract_sell.json create mode 100644 tests/test_myriad_amm_trading.py diff --git a/README.md b/README.md index 40c5d11..6116c5e 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ No silent gaps: if we weren't recording at a moment, you get the reason instead | | [Matchbook](https://www.matchbook.com) | `matchbook` | ✓ no account | ✓ your own login | polled from REST | one book per runner, back and lay read as YES | | | [ProphetX](https://www.prophetx.co) | `prophetx` | ✓ sports (Trading API key pair, or a Market Data API key) | ✓ | WebSocket (Trading API key pair) | one book per line, both sides read it | | | [Azuro](https://azuro.org) | `azuro` | ✓ sports, no key | ✓ bets with a worst price | WebSocket (no key) | no book: the pool's quoted odds, one level a side sized by its limits | -| | [Myriad](https://myriad.markets) | `myriad` | ✓ order-book and AMM markets, sports | ✓ order book | WebSocket (no key) | one book per market, both sides read it; AMM books built from the pool | +| | [Myriad](https://myriad.markets) | `myriad` | ✓ order-book and AMM markets, sports | ✓ order book; AMM as market orders | WebSocket (no key) | one book per market, both sides read it; AMM books built from the pool | Betfair is a betting exchange, not a prediction market, read here the same way: each runner (selection) is a yes/no market, `betfair:1.234567890-47972`, and the Betfair market holding the runners is the event. Every Betfair call needs your own Betfair account and application key, and the API is refused from several countries (the US, Germany, France, the Netherlands, Singapore, India, Turkey, the UAE and others). Betfair's keys are for your own betting: showing its prices to anyone else needs a data licence from Betfair. See [capabilities](docs/capabilities.md#betfair). @@ -103,7 +103,7 @@ ProphetX, a US peer-to-peer sports exchange, is read the way SX Bet is: each lin Azuro is an onchain sports betting protocol: a shared liquidity pool takes every bet at the odds it quotes, so there is no order book and nothing rests. A two-outcome condition ("Total Goals Over/Under 2.5") is one yes/no market, `azuro:`, with the lower outcome id as YES; a condition with three or more outcomes (1X2, correct score) is one market per outcome, `azuro:-`, where only YES can be bought. The game is the event. Prices are `1 / odds`. Ids are the same on every chain; `Azuro(chain=...)` picks the pool read (Polygon USDT by default, Base WETH). Bets are EIP-712 messages signed by your wallet and placed on chain by Azuro's relayer. Azuro's bet API and streams are refused from some regions. See [capabilities](docs/capabilities.md#azuro). -Myriad runs order-book markets on BNB Chain and AMM (pool) markets on BNB Chain, Abstract and Linea behind one API. A market id carries its network, and `amm` for a pool market, since the two kinds number their markets separately: `myriad:56-2385` is an order-book market, `myriad:56-amm-2385` an unrelated pool market. Order entry is on the order book; a pool trade is an on-chain transaction this library does not send. See [capabilities](docs/capabilities.md#myriad). +Myriad runs order-book markets on BNB Chain and AMM (pool) markets on BNB Chain, Abstract and Linea behind one API. A market id carries its network, and `amm` for a pool market, since the two kinds number their markets separately: `myriad:56-2385` is an order-book market, `myriad:56-amm-2385` an unrelated pool market. Order entry covers both: signed orders on the order book, and market orders against a pool, each sent as one on-chain transaction from the wallet. See [capabilities](docs/capabilities.md#myriad). ## Why Synpath diff --git a/docs/api.md b/docs/api.md index 4de4820..6ecf1fe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -196,7 +196,9 @@ Myriad needs no key (an API key and secret raise its rate limit). It takes `network` (56 BNB Chain, 2741 Abstract, 59144 Linea) on `fetch_markets` and `fetch_events`, and adds `fetch_event(id)`, `fetch_topics()`, `fetch_tags(type)` and `raw_market(id)`. Any id also takes the market's slug. See -[Myriad](capabilities.md#myriad). +[Myriad](capabilities.md#myriad). Both kinds trade through `MyriadTrading`: +order-book markets with signed orders, AMM markets as market orders sent on +chain ([Trading](trading.md#myriad)). ```python myriad = synpath.Myriad() # no key needed diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..f8c49fa 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -432,6 +432,12 @@ price, from the market's own table of basis points by cent (3 bps at 1c, 180 at 50c; `bps_by_price`); a maker pays nothing. A pool charges a share of the value traded on each side, set per market (`share_of_value`), often zero. +Order entry covers both kinds. Order-book orders are signed EIP-712 orders; +an AMM market trades as market orders only (`market`, `ioc`, `fok` at a worst +price), each one transaction from the wallet to the network's pool contract, +its size and on-chain bound worked out from the pool, and its fill read from +the receipt ([Trading](trading.md#myriad)). + No key for market data, at 30 requests per 10 seconds; an API key and its secret (`Myriad(api_key=..., api_secret=...)`) raise that to 200 a second, and then every request is HMAC-signed, since the venue refuses a bare key. Check @@ -529,7 +535,7 @@ onboarded firms. | Method | `kalshi` | `polymarket` | `polymarket_us` (retail) | `polymarket_us` (exchange) | `opinion` | `hyperliquid` | `predict_fun` | `limitless` | `sx_bet` | `novig` | `matchbook` | `prophetx` | `azuro` | `myriad` | `gemini` | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; order books only | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | +| `create_order`, `create_orders` | yes | yes | yes | yes | one at a time; `market` and `ioc` are limits with the rest cancelled | yes, a batch is one signed action; `market` and `ioc` are `Ioc` limits | one at a time; `market`, `ioc` and `fok` are the venue's `MARKET` strategy at the worst price given | one at a time; `market` and `ioc` are the venue's fill-and-kill at the worst price given | yes, ten a request; `market` and `ioc` are the venue's `IOC` at the worst price given, `fok` its `FOK` | yes, 256 a request, all or nothing; queued (`pending`) until the private stream says it rests; `market` and `ioc` are `IOC` at the worst price given, `fok` `FOK`, post-only `PO` | yes, fifty a request; `market` and `ioc` are offers at the worst price given with the rest cancelled (after the in-play delay); no `fok`, `gtd` or post-only | one at a time (the batch route is off on the default tier); answered before matching, so `pending`; `market` and `ioc` are the venue's `fillOrKill` at the worst price given, `post_only` its `ALO`; no `fok` or `gtd` | one at a time; `market`, `ioc` and `fok` are a bet with minimum odds `1 / price`, taken whole by the pool or refused | yes, 200 a request; `market` and `ioc` are the venue's `FAK` at the worst price given, `fok` its `FOK`, post-only its `PO`; AMM markets take `market`, `ioc` and `fok` only, each one on-chain transaction bounded by the worst average price | yes, twenty a request; `market` and `ioc` are the venue's `immediate-or-cancel` at the worst price given, `fok` its `fill-or-kill` | | `cancel_order`, `cancel_orders`, `cancel_all_orders` | yes | yes | yes | yes | one at a time; `cancel_all_orders` lists then cancels | yes; `cancel_all_orders` is one action | yes, up to 100 a request, off the book (not on chain) | yes, up to 50 a request; `cancel_all_orders` per market | yes, up to 100 a request; `cancel_all_orders` per market or account-wide | yes, up to 256 a request; `cancel_all_orders` per market or the whole subaccount | yes, fifty a request; `cancel_all_orders` per runner, market or account-wide | one at a time; `cancel_all_orders` per line (both sides) or account-wide, in the background | no: a signed bet cannot be cancelled; `cash_out` where the venue offers it | yes, up to 200 a request, each repeating the signed order; `cancel_all_orders` one signed message, per market or account-wide | yes, twenty a request; `cancel_all_orders` lists then cancels | | `edit_order` | yes, amend or decrease | no; an edit is a cancel and a new order | yes, price, quantity and time in force | yes | no | no | no | no | no | no | yes, odds and remaining stake in place | no | no | yes, the venue's atomic cancel-and-place (the queue place is lost) | no | | `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..6aa5c6a 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -100,7 +100,7 @@ The variables each venue reads: | Hyperliquid | `HYPERLIQUID_PRIVATE_KEY` (an API wallet's key is safest: it trades but cannot withdraw), `HYPERLIQUID_ACCOUNT_ADDRESS` (the account an API wallet trades for; not needed with the account's own key), `HYPERLIQUID_TESTNET` (`1` for the test network) | | predict.fun | `PREDICT_FUN_API_KEY` (mainnet market data and trading; from developers.predict.fun), `PREDICT_FUN_PRIVATE_KEY` (trading; a plain wallet's key, or a Predict account's owner key: the Privy wallet exported under Account -> Settings), `PREDICT_FUN_ACCOUNT_ADDRESS` (the Predict account's deposit address; unset for a plain wallet), `PREDICT_FUN_TESTNET` (`1` for the test network), `PREDICT_FUN_RPC_URL` (optional; the BNB Chain node the balance is read from) | | Limitless | `LIMITLESS_PRIVATE_KEY` (the wallet connected on limitless.exchange, in EOA trading mode), `LIMITLESS_API_TOKEN_ID` and `LIMITLESS_API_SECRET` (a scoped API token derived on limitless.exchange: profile, API tokens, Derive), `LIMITLESS_RPC_URL` (optional; the Base node the balance is read from) | -| Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from) | +| Myriad | `MYRIAD_PRIVATE_KEY` (signs orders and cancels, and the sign-in when there is no key), `MYRIAD_API_KEY` and `MYRIAD_API_SECRET` (optional together: HMAC-signed requests; the venue refuses a key without its signature), `MYRIAD_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address when no private key names it), `MYRIAD_RPC_URL` (optional; the BNB Chain node the balance is read from and AMM trades are sent to) | | SX Bet | `SX_BET_API_KEY` (market data streams, reading the account, cancels and the heartbeat; generated on sx.bet under Account -> Overview -> API Credentials), `SX_BET_PRIVATE_KEY` (optional; placing orders only: the wallet you sign in to sx.bet with, the account's own key, not its proxy wallet's), `SX_BET_ACCOUNT_ADDRESS` (optional; the wallet's public 0x address, not its private key, which names the account stream's channels when no private key is set; the venue documents no route that returns a key's owner), `SX_BET_TESTNET=1` (optional; the Toronto test network) | | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | @@ -774,8 +774,9 @@ some regions with a 503; check where Azuro may be used before betting. ## Myriad `MyriadTrading`, built to Myriad's API reference, for its order book on BNB -Chain. Order hashes and signatures are checked against an independent -EIP-712 encoder and fixed known answers. +Chain and its AMM (pool) markets on BNB Chain, Abstract and Linea. Order +hashes and signatures are checked against an independent EIP-712 encoder and +fixed known answers; pool calls against the venue's own quote calldata. Every order is the venue's nine-field EIP-712 `Order` (trader, market, outcome, side, amount, price, minimum fill, nonce, expiration), signed by the @@ -822,9 +823,36 @@ from the wallet's public tape with their orders and fees, and share balances book and AMM alike, netted per binary market on the YES leg. The balance is the wallet's USD1 on BNB Chain, less what open buy orders reserve. -AMM markets trade through on-chain contract calls (the venue's quote route -returns the calldata), one per trade with gas, on BNB Chain, Abstract and -Linea; they are not traded here, and an order for one is refused. +AMM markets (`myriad:56-amm-120473`, one outcome of a larger pool +`myriad:2741-amm-412-3`) trade as market orders: each is one transaction from +the wallet to the network's `PredictionMarket` contract, which pays its gas in +the chain's coin. There is nothing to rest, so a pool order is `market`, +`ioc` or `fok` with a worst price; a `gtc` or `gtd` limit and post-only are +refused. The worst price bounds the average price, fees in: the order is sized +from the pool read on chain, `market` and `ioc` trading the most shares up to +the quantity whose average is within it, `fok` the whole quantity or nothing +(an order that fits nothing is `canceled` and sends nothing). The bound goes on +chain as the contract's own guard, the minimum shares a buy's value must buy +(`value / worst`) or the most shares a sell may burn for its payout, so the +transaction reverts rather than fill worse if the pool moves first. YES and NO +are outcomes 0 and 1 of a two-outcome pool, as on the order book; on one +outcome of a larger pool YES is that outcome, and NO, the rest of the pool, is +not one trade. A sell (`reduce_only`) checks the shares held, a buy the +token's balance and its approval: `approve_amm(market_id)` approves the +market's token (USD1 or USDT, USDC.e, USDC) to that network's contract once. +`create_order` waits for the receipt (`params={"wait": False}` returns it +`pending`, its id the transaction hash, for `fetch_order` to read again) and +reports the fill from the receipt's `MarketActionTx`: shares, average price and +the fee, a share of the value set per market (`share_of_value`; on a buy it +comes out of the value spent, on a sell it is on top of the payout). The price +is the value over the shares, fees in, as the account's history gives it, and +the fill's id is the history's, so `fetch_my_trades`, which reads order-book +and pool trades alike, shows the same trade once. A reverted trade comes back +`rejected`. `plan_amm_order(request)` sizes an order and builds its call +without sending it, and `redeem_amm(market_id)` claims a resolved market's +winnings (or a voided one's shares). Nodes default to public ones; +`MYRIAD_RPC_URL` sets BNB Chain's and `rpc_urls={2741: ..., 59144: ...}` the +others. Check Myriad's terms for where it may be used before trading. diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..9f42e38 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -358,7 +358,7 @@ class MyriadCredentials: account_address: str | None = None """The wallet's address, when no private key names it. Public.""" rpc_url: str | None = None - """A BNB Chain node for the balance read. A public one when unset.""" + """A BNB Chain node for the balance read and AMM trades there. A public one when unset.""" _public = ("api_key", "account_address", "rpc_url") __repr__ = _redacted_repr diff --git a/synpath/trading/myriad.py b/synpath/trading/myriad.py index af273e2..4312ff2 100644 --- a/synpath/trading/myriad.py +++ b/synpath/trading/myriad.py @@ -32,25 +32,39 @@ **Fees** are taker-only: a share of the notional by price, from the market's table (`bps_by_price`). Makers pay nothing. -AMM markets trade on-chain, one contract call per trade with gas, through the -venue's quote route; that is not built here, and an order for one is refused. -Approvals are `approval_calls()`, for the wallet to send once; split, merge -and redeem calldata come from `position_calldata()`. +**AMM markets** (`myriad:56-amm-120473`, one outcome of many +`myriad:2741-amm-412-3`) trade against the pool, one contract call per trade +that the wallet sends and pays gas for. They are taker-only: a `market`, +`ioc` or `fok` order at a worst price, which bounds the average price paid +(fees in) or received. The order is sized from the pool read on chain: `ioc` +and `market` trade the most shares up to the quantity whose average is +within the worst price, `fok` the whole quantity or nothing. The bound goes +on chain as the contract's minimum shares out (a buy) or maximum shares in +(a sell), so the transaction reverts rather than fill worse. The fill (shares, +value, fee) is read from the receipt's `MarketActionTx`. A buy needs the +market's token approved to the network's `PredictionMarket` once +(`approve_amm()`); `redeem_amm()` claims a resolved market's winnings. + +Order-book approvals are `approval_calls()`, for the wallet to send once; +split, merge and redeem calldata come from `position_calldata()`. """ from __future__ import annotations import asyncio import json +import math import re +import secrets import time -from decimal import Decimal +from dataclasses import dataclass, field +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from typing import Any from urllib.parse import urlencode from .. import ids from ..base import AsyncHttpClient, Capability from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound -from ..myriad import ORDER_BOOK_NETWORK, Ref, iso_ms, native_id, parse_id +from ..myriad import ORDER_BOOK_NETWORK, Ref, _after_buy, _after_sell, amm_fees, is_amm, iso_ms, native_id, parse_id from ..types import Page from . import myriad_signing as sig from .base import TradingExchange @@ -60,12 +74,16 @@ ) from .limiter import BudgetLimiter, Priority from .polymarket import to_yes_leg, wire_leg -from .polymarket_signing import WalletSigner +from .polymarket_signing import MAX_UINT256, WalletSigner from .types import ( Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, SettlementState, Side, TimeInForce, VENUE_ORDER_TYPES, ) +AMM_MARGIN = 1e-6 +"""How far inside the worst price an AMM order is sized, so that rounding +never puts the expected trade on the bound itself.""" + VENUE = "myriad" API_URL = "https://api-v2.myriadprotocol.com" RPC_URL = "https://bsc-dataseed.bnbchain.org" @@ -238,10 +256,14 @@ def order_of_update(data: dict[str, Any], *, account: Account | None = None) -> ) -def fill_of_action(row: dict[str, Any], *, account: Account | None = None) -> Fill | None: +def fill_of_action(row: dict[str, Any], *, account: Account | None = None, amm: bool = False, + many: bool = False) -> Fill | None: """One trade in the account's history (`/users/:address/events`) as a fill on the YES leg, or `None` for anything else (claims, liquidity). The - history names neither the order nor the fee.""" + history names neither the order nor the fee. A pool trade (`amm`) is on + the pool's market id; on a pool of more than two outcomes (`many`) each + outcome is its own market, YES that outcome. A pool trade's price is its + value over its shares, fees in.""" action = str(row.get("action") or "") if action not in ("buy", "sell"): return None @@ -249,14 +271,14 @@ def fill_of_action(row: dict[str, Any], *, account: Account | None = None) -> Fi if shares <= 0: return None outcome = int(row.get("outcomeId") or 0) - side, price = to_yes_leg(_outcome(outcome), action.upper(), value / shares) + side, price = to_yes_leg("yes" if many else _outcome(outcome), action.upper(), value / shares) stamp = iso_ms(row.get("timestamp")) or 0 return Fill( id=f"{row.get('txId') or stamp}:{outcome}:{action}", order_id="", venue=VENUE, account=account, - market_id=market_id_of(row.get("networkId"), row.get("marketId")), + market_id=market_id_of(row.get("networkId"), row.get("marketId"), amm=amm, outcome=outcome if many else None), side=side, price=price or Decimal("0"), quantity=shares, @@ -317,10 +339,13 @@ def fills_of_trade(data: dict[str, Any], *, address: str, account: Account | Non RESOLVED = ("won", "lost", "claimed", "voided") -def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None) -> list[Position]: +def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None, + many: set[tuple[int, int]] | None = None) -> list[Position]: """The account's portfolio rows (one per market and outcome) as positions: a binary market's two outcomes netted on the YES leg with both kept, and - an outcome of a many-outcome AMM market as its own market.""" + an outcome of a many-outcome AMM market as its own market. `many` names + the pools (`(network, market)`) known to have more than two outcomes; a + row on an outcome above 1 shows it too.""" by_market: dict[tuple[int, int, bool], list[dict[str, Any]]] = {} for row in rows: if row.get("marketId") is None: @@ -329,8 +354,8 @@ def positions_of(rows: list[dict[str, Any]], *, account: Account | None = None) by_market.setdefault(key, []).append(row) out: list[Position] = [] for (network, market, amm), group in by_market.items(): - many = amm and any(int(r.get("outcomeId") or 0) > 1 for r in group) - if many: + several = amm and ((network, market) in (many or set()) or any(int(r.get("outcomeId") or 0) > 1 for r in group)) + if several: for row in group: out.append(_position(network, market, amm, {int(row.get("outcomeId") or 0): row}, account=account, outcome=int(row.get("outcomeId") or 0))) @@ -397,12 +422,245 @@ def error_of(exc: ExchangeError, *, client_order_id: str | None = None) -> Excha return OrderRejected(f"{VENUE}: {text}", reason=code or None, info=body, body=exc.body, status=exc.status) +# --------------------------------------------------------------------------- +# AMM orders +# --------------------------------------------------------------------------- + +def _bisect(rising: Any, target: float, low: float, high: float) -> tuple[float, float]: + """`(low, high)` bracketing where the increasing `rising` reaches `target`.""" + for _ in range(200): + middle = (low + high) / 2 + if middle in (low, high): + break + if rising(middle) < target: + low = middle + else: + high = middle + return low, high + + +def amm_buy_shares(pools: list[float], index: int, value: float, fee: float) -> float: + """The shares of outcome `index` that `value` buys, fees taken out of it.""" + if value <= 0: + return 0.0 + return _after_buy(pools, index, value * (1 - fee))[1] + + +def amm_buy_cost(pools: list[float], index: int, shares: float, fee: float) -> float: + """The value, fees in, that buys `shares` of outcome `index`.""" + if shares <= 0: + return 0.0 + high = 1.0 + for _ in range(200): + if amm_buy_shares(pools, index, high, fee) >= shares: + break + high *= 2 + return _bisect(lambda v: amm_buy_shares(pools, index, v, fee), shares, 0.0, high)[1] + + +def _sell_ceiling(pools: list[float], index: int) -> float: + return min(n for j, n in enumerate(pools) if j != index) + + +def amm_sell_shares(pools: list[float], index: int, value: float, fee: float) -> float: + """The shares of outcome `index` a payout of `value`, after fees, takes: + the pool pays out `value / (1 - fee)` and keeps the fee.""" + if value <= 0: + return 0.0 + gross = value / (1 - fee) + if gross >= _sell_ceiling(pools, index): + return math.inf + return _after_sell(pools, index, gross)[1] + + +def amm_sell_value(pools: list[float], index: int, shares: float, fee: float) -> float: + """What selling `shares` of outcome `index` pays out, after fees.""" + if shares <= 0: + return 0.0 + ceiling = _sell_ceiling(pools, index) * (1 - fee) * (1 - 1e-12) + return _bisect(lambda v: amm_sell_shares(pools, index, v, fee), shares, 0.0, ceiling)[0] + + +def _units(value: float | Decimal, decimals: int, rounding: str) -> int: + return int((Decimal(str(value)) * (Decimal(10) ** decimals)).to_integral_value(rounding=rounding)) + + +@dataclass +class AmmPlan: + """One pool trade, sized and bounded before it is sent. Amounts in tokens + unless named `_units` (the token's smallest unit); prices are the + outcome's own, fees in.""" + + network: int + market: int + outcome: int + """The outcome traded.""" + action: str + """`buy` or `sell`.""" + worst: Decimal + """The worst average price accepted, on the outcome traded.""" + requested: Decimal + """The shares asked for.""" + shares: Decimal = Decimal("0") + """The shares expected, from the pool as read.""" + value: Decimal = Decimal("0") + """Spent, fees in (a buy), or paid out, after fees (a sell).""" + average: Decimal | None = None + fee: Decimal = Decimal("0") + value_units: int = 0 + bound_units: int = 0 + """The on-chain bound: the minimum shares out (a buy), the maximum shares in (a sell).""" + decimals: int = 18 + contract: str = "" + token: str = "" + symbol: str = "" + calldata: str = "" + reason: str | None = None + """Why nothing is traded, when the pool fills nothing within the worst price.""" + info: dict[str, Any] = field(default_factory=dict) + + @property + def fills(self) -> bool: + return self.reason is None + + @property + def bound(self) -> Decimal: + return Decimal(self.bound_units) / (Decimal(10) ** self.decimals) + + +def plan_amm_trade( + pools: list[float], *, index: int, action: str, shares: Decimal, worst: Decimal, buy_fee: float, sell_fee: float, + decimals: int, partial: bool, held: Decimal | None = None, +) -> dict[str, Any]: + """Size a pool trade and its on-chain bound. `worst` bounds the average + price, fees in: a buy spends `value` for at least `value / worst` shares, + a sell is paid `value` for at most `value / worst`. `partial` (`ioc`) + trades the most shares up to `shares` whose average is within `worst`; + otherwise all of them or nothing. Returns the plan's numbers, or a + `reason` when nothing trades.""" + want, bound = float(shares), float(worst) + if action == "buy": + def average(q: float) -> float: + return amm_buy_cost(pools, index, q, buy_fee) / q + within = lambda q: average(q) <= bound * (1 - AMM_MARGIN) # noqa: E731 + else: + def average(q: float) -> float: + return amm_sell_value(pools, index, q, sell_fee) / q + within = lambda q: average(q) >= bound * (1 + AMM_MARGIN) # noqa: E731 + size = want + if not within(size): + if not partial: + return {"reason": f"the pool fills {shares} at an average of {average(want):.6f}, worse than {worst}"} + low, high = 0.0, want + for _ in range(200): + middle = (low + high) / 2 + if middle in (low, high): + break + low, high = (middle, high) if within(middle) else (low, middle) + size = low + if size <= 0 or not within(size) or size * 10**decimals < 1: + return {"reason": f"the pool fills nothing at {worst} or better"} + if action == "buy": + value_units = _units(amm_buy_cost(pools, index, size, buy_fee), decimals, ROUND_FLOOR) + value = Decimal(value_units) / (Decimal(10) ** decimals) + expected = amm_buy_shares(pools, index, float(value), buy_fee) + bound_units = int((Decimal(value_units) / worst).to_integral_value(rounding=ROUND_CEILING)) + fee = value * Decimal(str(buy_fee)) + else: + value_units = _units(amm_sell_value(pools, index, size, sell_fee), decimals, ROUND_FLOOR) + value = Decimal(value_units) / (Decimal(10) ** decimals) + expected = amm_sell_shares(pools, index, float(value), sell_fee) + bound_units = int((Decimal(value_units) / worst).to_integral_value(rounding=ROUND_FLOOR)) + if held is not None: + bound_units = min(bound_units, _units(held, decimals, ROUND_FLOOR)) + fee = value / (1 - Decimal(str(sell_fee))) - value if sell_fee else Decimal("0") + if value_units <= 0 or expected <= 0: + return {"reason": f"the pool fills nothing at {worst} or better"} + shares_out = Decimal(str(expected)) + return {"value_units": value_units, "bound_units": bound_units, "value": value, "shares": shares_out, + "average": value / shares_out, "fee": fee} + + +def amm_time_in_force(request: OrderRequest) -> bool: + """Whether an AMM order may fill in part (`market`, `ioc`) or must fill + whole (`fok`). A pool trade fills at once or not at all, so a resting + order is refused.""" + if request.type not in VENUE_ORDER_TYPES: + raise InvalidOrder(f"{VENUE}: {request.type.value} is held by the execution engine, not the venue; " + f"submit it through the engine") + tif = request.time_in_force + resting = request.post_only or (request.type == OrderType.LIMIT and tif not in (TimeInForce.IOC, TimeInForce.FOK)) + if resting: + what = "post-only" if request.post_only else f"a {tif.value} limit" + raise InvalidOrder(f"{VENUE}: AMM markets are taker-only -- a pool trade fills at once or not at all, so " + f"{what} cannot rest; send a market, ioc or fok order with the worst price") + if request.price is None: + raise InvalidOrder(f"{VENUE}: a price is required -- an AMM order is sent with the worst average price " + f"you give") + if not Decimal("0") < D(request.price) < Decimal("1"): + raise InvalidOrder(f"{VENUE}: price {request.price} is outside (0, 1)") + return tif != TimeInForce.FOK + + +def amm_leg(request: OrderRequest, ref: Ref, outcomes: int) -> tuple[int, str, Decimal]: + """`(outcome, action, worst)`: the pool outcome an order trades, `buy` + or `sell`, and its worst price on that outcome. On a two-outcome pool YES + is outcome 0 and NO outcome 1, as on the order book; on one outcome of + many, YES is that outcome and NO (the rest of the pool) is not one trade.""" + side, venue_side = wire_leg(request) + action = "buy" if venue_side == "BUY" else "sell" + if ref.outcome is not None: + if ref.outcome >= outcomes: + raise InvalidOrder(f"{VENUE}: {ref.native} names outcome {ref.outcome}; the pool has {outcomes}") + if side == "no": + hint = "pass reduce_only to sell the outcome held" if action == "buy" else "buy the other outcomes' markets" + raise InvalidOrder(f"{VENUE}: NO on {ref.native} is the rest of the pool, not one trade; {hint}") + return ref.outcome, action, D(request.price) + if outcomes != 2: + raise InvalidOrder(f"{VENUE}: {ref.native} has {outcomes} outcomes; trade one of them, " + f"{ref.native}-") + return (0 if side == "yes" else 1), action, (D(request.price) if side == "yes" else Decimal("1") - D(request.price)) + + +def amm_fill(action: dict[str, Any], *, tx_hash: str, plan: AmmPlan, many: bool, order_id: str, + client_order_id: str | None = None, account: Account | None = None) -> Fill: + """A decoded `MarketActionTx` as this account's fill, on the YES leg and + with the same id the account's history gives the trade. The price is the + value over the shares, fees in, as in the history; `fee` is that fee.""" + scale = Decimal(10) ** plan.decimals + shares, value = Decimal(int(action["shares"])) / scale, Decimal(int(action["value"])) / scale + outcome = int(action["outcome"]) + buying = int(action["action"]) == sig.AMM_BUY + rate = Decimal(str(plan.info.get("buy_fee" if buying else "sell_fee") or 0)) + fee = value * rate if buying else (value / (1 - rate) - value if rate else Decimal("0")) + side, price = to_yes_leg("yes" if many or outcome == 0 else "no", "BUY" if buying else "SELL", + value / shares if shares > 0 else None) + return Fill( + id=f"{tx_hash}:{outcome}:{'buy' if buying else 'sell'}", + order_id=order_id, + client_order_id=client_order_id, + venue=VENUE, + account=account, + market_id=market_id_of(plan.network, plan.market, amm=True, outcome=outcome if many else None), + side=side, + price=price or Decimal("0"), + quantity=shares, + fee=fee, + fee_currency=plan.symbol or None, + liquidity=Liquidity.TAKER, + settlement=SettlementState.CONFIRMED, + timestamp=int(action.get("timestamp") or 0) * 1000, + info={"tx_hash": tx_hash, "outcome": outcome, "value": str(value), "fee_in_price": True}, + ) + + # --------------------------------------------------------------------------- # Adapter # --------------------------------------------------------------------------- class MyriadTrading(TradingExchange): - """Myriad order entry, on the order book. + """Myriad order entry: signed orders on the order book, and market + orders against an AMM market's pool, sent as the wallet's transactions. ```python from synpath.trading.credentials import load_credentials, require @@ -416,8 +674,11 @@ class MyriadTrading(TradingExchange): id = VENUE name = "Myriad" has: dict[str, Capability] = { + # Order books: signed orders. AMM markets: market, ioc or fok only, + # one on-chain transaction each. "create_order": True, # Up to 200 a request; the venue checks collateral at match time. + # AMM orders go one transaction at a time. "create_orders": True, "cancel_order": True, # Up to 200 a request. @@ -455,6 +716,7 @@ def __init__( timeout: float = 30.0, client: Any = None, rpc_client: Any = None, + rpc_urls: dict[int, str] | None = None, ): import httpx @@ -471,6 +733,9 @@ def __init__( shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) self.http = AsyncHttpClient(url, limiter=None, client=shared, venue=VENUE) self.rpc_url = rpc_url or credentials.rpc_url or RPC_URL + self.rpc_urls = {**sig.AMM_RPC_URLS, **(rpc_urls or {}), ORDER_BOOK_NETWORK: self.rpc_url} + """A node per network (BNB Chain 56, Abstract 2741, Linea 59144) for + AMM trades; public ones unless given.""" self.rpc = rpc_client or httpx.AsyncClient(timeout=20, follow_redirects=True) self._own_catalog = catalog is None if catalog is None: @@ -483,6 +748,10 @@ def __init__( self._signed: dict[str, dict[str, Any]] = {} """Order hash -> the signed `{order, signature}` a cancel repeats.""" self._session: dict[str, Any] | None = None + self._amm_orders: dict[str, tuple[Order, AmmPlan, bool]] = {} + """Transaction hash -> an AMM order sent here, its plan, and whether its pool has many outcomes.""" + self._outcome_counts: dict[tuple[int, int], int] = {} + self._tx_lock = asyncio.Lock() @property def keyed(self) -> bool: @@ -582,15 +851,20 @@ async def subscription_token(self, channel: str) -> str: raise AuthenticationError(f"{VENUE}: no subscription token for {channel}") return str(raw["token"]) - async def _ob_ref(self, market_id: str) -> Ref: - """An order-book market's parts; an AMM market is refused.""" + async def _ref(self, market_id: str) -> Ref: + """A market's parts, either kind; a slug is looked up.""" native = ids.native(VENUE, market_id) ref = parse_id(native) if ref is None: ref, _ = await asyncio.to_thread(self.catalog._resolve, native) + return ref + + async def _ob_ref(self, market_id: str) -> Ref: + """An order-book market's parts; an AMM market is refused.""" + ref = await self._ref(market_id) if ref.amm: - raise InvalidOrder(f"{VENUE}: {ref.native} is an AMM market, traded on-chain through the venue's quote " - f"route; this adapter places order-book orders only") + raise InvalidOrder(f"{VENUE}: {ref.native} is an AMM market: it has no orders, cancels or calldata " + f"routes; trade it with a market, ioc or fok order") if ref.network != ORDER_BOOK_NETWORK: raise InvalidOrder(f"{VENUE}: order books run on BNB Chain (56) only, not {ref.network}") return ref @@ -646,8 +920,13 @@ def _order(self, request: OrderRequest, *, ref: Ref, order_id: str, contracts: D ) async def create_order(self, request: OrderRequest) -> Order: - """Sign and place one order. The venue answers once the order is on - the book; fills follow from the matcher.""" + """Sign and place one order. On an order book the venue answers once + the order is on the book, and fills follow from the matcher. On an + AMM market the trade is a transaction from the wallet; see + `create_amm_order`.""" + ref = await self._ref(request.market_id) + if ref.amm: + return await self.create_amm_order(request) time_in_force_of(request) signer = self._need_signer("placing an order") ref = await self._ob_ref(request.market_id) @@ -676,6 +955,9 @@ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exce prepared: list[tuple[int, OrderRequest, dict[str, Any], str, Decimal, Ref]] = [] for i, request in enumerate(requests): try: + if (await self._ref(request.market_id)).amm: + results[i] = await self.create_amm_order(request) + continue time_in_force_of(request) signer = self._need_signer("placing an order") ref = await self._ob_ref(request.market_id) @@ -707,6 +989,9 @@ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exce async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order: """Cancel one order (repeating it and its signature) and read it back.""" + if order_id.lower() in self._amm_orders: + raise InvalidOrder(f"{VENUE}: {order_id} is an AMM trade, one transaction filled or reverted at once; " + f"there is nothing to cancel") entry = await self._signed_entry(order_id) await self._call("DELETE", f"/orders/{order_id}", body={**entry, "network_id": ORDER_BOOK_NETWORK}, kind="write", priority=Priority.HIGH) @@ -790,6 +1075,15 @@ async def edit_order(self, request: EditRequest, *, current: Order | None = None return order.model_copy(update={"queue_priority_preserved": False}) async def fetch_order(self, order_id: str) -> Order: + """An order by its hash; an AMM order sent here by its transaction + hash, its receipt read again while it is pending.""" + if order_id.lower() in self._amm_orders: + order, plan, many = self._amm_orders[order_id.lower()] + if order.status == OrderStatus.PENDING: + receipt = await self._rpc(plan.network, "eth_getTransactionReceipt", [order.id]) + if receipt: + order = self._settle_amm(order, plan, many, receipt) + return order row = await self._call("GET", f"/orders/{order_id}") or {} if not row.get("orderHash"): raise OrderNotFound(f"{VENUE}: no order {order_id}") @@ -804,6 +1098,15 @@ async def fetch_orders( word = {"closed": "filled", "canceled": "cancelled"}.get(status or "", status) if word and word not in ("open", "filled", "cancelled", "expired"): raise BadRequest(f"{VENUE}: status is open, filled, cancelled or expired, not {status!r}") + ref = await self._ref(market_id) if market_id else None + if ref is not None and ref.amm: + # A pool has no orders at the venue: only the trades sent here. + wanted = ids.qualify(VENUE, ref.native) + mine = [o for o, _, _ in self._amm_orders.values() if o.market_id == wanted and ( + word is None or (word == "open" and o.status == OrderStatus.PENDING) + or (word == "filled" and o.status == OrderStatus.CLOSED) + or (word == "cancelled" and o.status == OrderStatus.CANCELED))] + return Page(sorted(mine, key=lambda o: o.created_at or 0, reverse=True), next_cursor=None) market = (await self._ob_ref(market_id)).market if market_id else None page = int(cursor) if cursor and cursor.isdigit() else 1 raw = await self._call("GET", "/orders", params={ @@ -831,24 +1134,41 @@ async def fetch_my_trades( self, *, market_id: str | None = None, order_id: str | None = None, since: int | None = None, limit: int | None = None, cursor: str | None = None, ) -> Page[Fill]: - """One page of this account's order-book trades, newest first, from - its history. The history names neither the order behind a trade nor - its fee, so `order_id` cannot filter it.""" + """One page of this account's trades, newest first, from its history: + order book and AMM alike, or one market's. The history names neither + the order behind a trade nor its fee, so `order_id` cannot filter it. + Without a market, page `n` is the `n`th page of each kind, merged.""" if order_id: raise BadRequest(f"{VENUE}: the account's history does not name orders; read the order's filled " f"amount with fetch_order") address = self._need_address() - market = (await self._ob_ref(market_id)).market if market_id else None + ref = await self._ref(market_id) if market_id else None page = int(cursor) if cursor and cursor.isdigit() else 1 - raw = await self._call("GET", f"/users/{address}/events", params={ - "trading_model": "ob", "network_id": ORDER_BOOK_NETWORK, "market_id": market, - "since": since // 1000 if since else None, "page": page, "limit": min(limit or 100, 100), - }) or {} - rows = raw.get("data") or [] if isinstance(raw, dict) else [] - fills = [f for f in (fill_of_action(row, account=self.account) for row in rows) if f is not None] + size = min(limit or 100, 100) + kinds = [ref.amm] if ref is not None else [False, True] + fills: list[Fill] = [] + more = False + for amm in kinds: + raw = await self._call("GET", f"/users/{address}/events", params={ + "trading_model": "amm" if amm else "ob", + "network_id": ref.network if ref is not None else None if amm else ORDER_BOOK_NETWORK, + "market_id": ref.market if ref is not None else None, + "since": since // 1000 if since else None, "page": page, "limit": size, + }) or {} + rows = raw.get("data") or [] if isinstance(raw, dict) else [] + for row in rows: + many = False + if amm and row.get("marketId") is not None: + many = await self._many(int(row.get("networkId") or ORDER_BOOK_NETWORK), int(row["marketId"])) + if ref is not None and ref.outcome is not None and int(row.get("outcomeId") or 0) != ref.outcome: + continue + fill = fill_of_action(row, account=self.account, amm=amm, many=many) + if fill is not None: + fills.append(fill) + more = more or (bool((raw.get("pagination") or {}).get("hasNext")) if isinstance(raw, dict) else False) if since: fills = [f for f in fills if f.timestamp >= since] - more = bool((raw.get("pagination") or {}).get("hasNext")) if isinstance(raw, dict) else False + fills.sort(key=lambda f: f.timestamp, reverse=True) return Page(fills, next_cursor=str(page + 1) if more else None) # -- account -------------------------------------------------------------- @@ -867,7 +1187,13 @@ async def fetch_positions(self, *, market_id: str | None = None, event_id: str | if not (isinstance(raw, dict) and (raw.get("pagination") or {}).get("hasNext")) or page >= 50: break page += 1 - positions = positions_of(rows, account=self.account) + many: set[tuple[int, int]] = set() + for row in rows: + if row.get("executionMode") == 0 and row.get("marketId") is not None: + key = (int(row.get("networkId") or ORDER_BOOK_NETWORK), int(row["marketId"])) + if key not in many and int(row.get("outcomeId") or 0) <= 1 and await self._many(*key): + many.add(key) + positions = positions_of(rows, account=self.account, many=many) if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) positions = [p for p in positions if p.market_id == wanted] @@ -910,6 +1236,288 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency="USD1", info={"schedule": schedule.model_dump()}, ) + # -- AMM markets ---------------------------------------------------------- + + async def _many(self, network: int, market: int) -> bool: + """Whether a pool has more than two outcomes (read once, kept).""" + key = (network, market) + if key not in self._outcome_counts: + try: + raw = await asyncio.to_thread(self.catalog.raw_market, native_id(network, market, amm=True)) + self._outcome_counts[key] = len(raw.get("outcomes") or []) + except (MarketNotFound, BadRequest): + return False + return self._outcome_counts[key] > 2 + + async def _rpc(self, network: int, method: str, params: list[Any]) -> Any: + url = self.rpc_urls.get(network) + if not url: + raise BadRequest(f"{VENUE}: no node for network {network}; pass rpc_urls={{{network}: ...}}") + answer = (await self.rpc.post(url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params})).json() + if not isinstance(answer, dict) or answer.get("error") or "result" not in answer: + error = answer.get("error") if isinstance(answer, dict) else answer + raise ExchangeError(f"{VENUE}: network {network} rpc {method}: {error}", body=answer) + return answer["result"] + + async def _eth_call(self, network: int, to: str, data: str) -> str: + return str(await self._rpc(network, "eth_call", [{"to": to, "data": data}, "latest"])) + + async def _amm_market(self, market_id: str) -> tuple[Ref, dict[str, Any]]: + """An AMM market's parts and the venue's record of it.""" + ref = await self._ref(market_id) + if not ref.amm: + raise InvalidOrder(f"{VENUE}: {ref.native} is an order-book market, not an AMM one") + if ref.network not in sig.AMM_CONTRACTS: + raise InvalidOrder(f"{VENUE}: AMM markets on network {ref.network} are not traded here") + raw = await asyncio.to_thread(self.catalog.raw_market, ref.market_native) + if not is_amm(raw): + raise InvalidOrder(f"{VENUE}: {ref.native} is not an AMM market") + self._outcome_counts[(ref.network, ref.market)] = len(raw.get("outcomes") or []) + return ref, raw + + async def plan_amm_order(self, request: OrderRequest) -> AmmPlan: + """Size an AMM order from the pool as it stands on chain, and build + its call, without sending anything. `plan.fills` is false (with a + `reason`) when the pool fills nothing within the worst price.""" + partial = amm_time_in_force(request) + ref, raw = await self._amm_market(request.market_id) + if str(raw.get("state") or "") != "open": + raise InvalidOrder(f"{VENUE}: {ref.native} is {raw.get('state')}; a pool trades only while open") + outcomes = raw.get("outcomes") or [] + index, action, worst = amm_leg(request, ref, len(outcomes)) + token = raw.get("token") or {} + decimals = int(token.get("decimals") or 18) + contract = sig.AMM_CONTRACTS[ref.network] + scale = 10 ** decimals + pools_units = sig.decode_uint_array(await self._eth_call(ref.network, contract, sig.amm_pools_calldata(ref.market))) + if len(pools_units) != len(outcomes) or any(n <= 0 for n in pools_units): + raise ExchangeError(f"{VENUE}: the pool of {ref.native} read back {pools_units}") + pools = [n / scale for n in pools_units] + buy_fee, sell_fee = amm_fees(raw) + held: Decimal | None = None + if action == "sell": + address = self._need_address() + shares = sig.decode_holdings(await self._eth_call( + ref.network, contract, sig.amm_holdings_calldata(ref.market, address))) + held = Decimal(shares[index] if index < len(shares) else 0) / scale + if held < D(request.quantity): + raise InsufficientFunds(f"{VENUE}: selling {request.quantity} of outcome {index} on {ref.native}, " + f"but the wallet holds {held}") + numbers = plan_amm_trade(pools, index=index, action=action, shares=D(request.quantity), worst=worst, + buy_fee=buy_fee, sell_fee=sell_fee, decimals=decimals, partial=partial, held=held) + plan = AmmPlan( + network=ref.network, market=ref.market, outcome=index, action=action, worst=worst, + requested=D(request.quantity), decimals=decimals, contract=contract, token=str(token.get("address") or ""), + symbol=str(token.get("symbol") or ""), reason=numbers.get("reason"), + info={"pools": pools, "buy_fee": buy_fee, "sell_fee": sell_fee, "partial": partial, + "outcomes": len(outcomes), "held": str(held) if held is not None else None}, + ) + if plan.reason is None: + plan.shares, plan.value, plan.average, plan.fee = (numbers["shares"], numbers["value"], numbers["average"], + numbers["fee"]) + plan.value_units, plan.bound_units = numbers["value_units"], numbers["bound_units"] + plan.calldata = (sig.amm_buy_calldata(ref.market, index, plan.bound_units, plan.value_units) if action == "buy" + else sig.amm_sell_calldata(ref.market, index, plan.value_units, plan.bound_units)) + return plan + + def _amm_order(self, request: OrderRequest, plan: AmmPlan, *, order_id: str, status: OrderStatus, + many: bool, info: dict[str, Any]) -> Order: + return Order( + id=order_id, + client_order_id=request.client_order_id, + venue=VENUE, + account=request.account or self.account, + market_id=market_id_of(plan.network, plan.market, amm=True, outcome=plan.outcome if many else None), + side=request.side, + type=request.type, + time_in_force=request.time_in_force, + status=status, + price=D(request.price), + quantity=D(request.quantity), + filled=Decimal("0"), + remaining=Decimal("0") if status != OrderStatus.PENDING else D(request.quantity), + reduce_only=request.reduce_only, + created_at=int(time.time() * 1000), + book=request.book, + trader=request.trader, + tags=request.tags, + info=info, + ) + + async def create_amm_order(self, request: OrderRequest) -> Order: + """Trade against an AMM market's pool: one transaction from the + wallet, sized from the pool on chain and bounded by the worst price + (see `plan_amm_order`), then its receipt read for the fill. A buy + checks the token's balance and its approval to the pool's contract + first (`approve_amm`); a sell checks the shares held. + + `request.params`: `wait` (default true) waits for the receipt, up to + `timeout_s` (default 120). An order not yet mined comes back + `pending`, its id the transaction hash; `fetch_order` reads it again. + When the pool fills nothing within the worst price, nothing is sent + and the order comes back `canceled`, its id `unsent-...`.""" + signer = self._need_signer("trading on an AMM market") + plan = await self.plan_amm_order(request) + many = plan.info["outcomes"] > 2 + if not plan.fills: + unsent = self._amm_order(request, plan, order_id=f"unsent-{secrets.token_hex(16)}", + status=OrderStatus.CANCELED, many=many, + info={"amm": True, "reason": plan.reason, "plan": self._plan_info(plan)}) + self._amm_orders[unsent.id] = (unsent, plan, many) + return unsent + if plan.action == "buy": + balance = int(await self._eth_call(plan.network, plan.token, sig.balance_calldata(signer.address)), 16) + if balance < plan.value_units: + raise InsufficientFunds(f"{VENUE}: the trade spends {plan.value} {plan.symbol}; the wallet holds " + f"{Decimal(balance) / (Decimal(10) ** plan.decimals)}") + allowance = int(await self._eth_call(plan.network, plan.token, sig.allowance_calldata( + signer.address, plan.contract)), 16) + if allowance < plan.value_units: + raise InsufficientFunds(f"{VENUE}: {plan.symbol} is not approved to the pool's contract for " + f"{plan.value}; call approve_amm({request.market_id!r}) once") + tx_hash = await self._send(plan.network, plan.contract, plan.calldata, what=f"the {plan.action}") + order = self._amm_order(request, plan, order_id=tx_hash, status=OrderStatus.PENDING, many=many, + info={"amm": True, "tx_hash": tx_hash, "plan": self._plan_info(plan)}) + self._amm_orders[tx_hash.lower()] = (order, plan, many) + if request.params.get("wait", True) is False: + return order + receipt = await self._receipt(plan.network, tx_hash, timeout_s=float(request.params.get("timeout_s", 120))) + return self._settle_amm(order, plan, many, receipt) if receipt else order + + @staticmethod + def _plan_info(plan: AmmPlan) -> dict[str, Any]: + return {"outcome": plan.outcome, "action": plan.action, "worst": str(plan.worst), "shares": str(plan.shares), + "value": str(plan.value), "average": str(plan.average) if plan.average is not None else None, + "fee": str(plan.fee), "bound": str(plan.bound), "contract": plan.contract, "network": plan.network} + + def _settle_amm(self, order: Order, plan: AmmPlan, many: bool, receipt: dict[str, Any]) -> Order: + """The order once its transaction is mined: filled from the receipt's + `MarketActionTx`, or rejected when it reverted.""" + if int(str(receipt.get("status") or "0x0"), 16) != 1: + settled = order.model_copy(update={"status": OrderStatus.REJECTED, "remaining": Decimal("0"), "info": { + **order.info, "reason": "reverted: the pool moved past the worst price, or the funds changed", + "receipt": receipt}}) + self._amm_orders[order.id.lower()] = (settled, plan, many) + return settled + wanted = sig.AMM_BUY if plan.action == "buy" else sig.AMM_SELL + actions = [a for a in sig.market_actions(receipt, contract=plan.contract, user=self.address) + if a["market_id"] == plan.market and a["outcome"] == plan.outcome and a["action"] == wanted] + fills = [amm_fill(a, tx_hash=order.id, plan=plan, many=many, order_id=order.id, + client_order_id=order.client_order_id, account=order.account) for a in actions] + filled = sum((f.quantity for f in fills), Decimal("0")) + value = sum((Decimal(f.info["value"]) for f in fills), Decimal("0")) + fee = sum((f.fee or Decimal("0") for f in fills), Decimal("0")) + average = None + if filled > 0: + average = value / filled if many or plan.outcome == 0 else Decimal("1") - value / filled + whole = filled >= order.quantity * (1 - Decimal(str(AMM_MARGIN))) + settled = order.model_copy(update={ + "status": OrderStatus.CLOSED if whole else OrderStatus.CANCELED, "filled": filled, + "remaining": Decimal("0"), "average_price": average, "cost": value if plan.action == "buy" else None, + "fee": fee, "fee_currency": plan.symbol or None, + "last_fill_price": fills[-1].price if fills else None, "last_fill_quantity": fills[-1].quantity if fills else None, + "updated_at": int(time.time() * 1000), + "info": {**order.info, "fills": fills, "block": receipt.get("blockNumber"), + "gas_used": receipt.get("gasUsed")}, + }) + self._amm_orders[order.id.lower()] = (settled, plan, many) + return settled + + async def _send(self, network: int, to: str, data: str, *, what: str) -> str: + """Sign and send one transaction from the wallet; its hash. A call the + node's simulation says would revert is refused before it is sent.""" + signer = self._need_signer("sending a transaction") + async with self._tx_lock: + nonce = int(await self._rpc(network, "eth_getTransactionCount", [signer.address, "pending"]), 16) + gas_price = int(await self._rpc(network, "eth_gasPrice", []), 16) + priority_fee = int(await self._rpc(network, "eth_maxPriorityFeePerGas", []), 16) + try: + estimate = int(await self._rpc(network, "eth_estimateGas", [{"from": signer.address, "to": to, + "data": data}]), 16) + except ExchangeError as exc: + raise OrderRejected(f"{VENUE}: {what} would revert: {exc}", reason="simulation", body=exc.body) from None + tx = { + "chainId": network, "nonce": nonce, "to": to, "value": 0, "data": data, "gas": int(estimate * 1.2), + "maxFeePerGas": gas_price * 2 + priority_fee, "maxPriorityFeePerGas": priority_fee, "type": 2, + } + return str(await self._rpc(network, "eth_sendRawTransaction", [signer.sign_transaction(tx)])) + + async def _receipt(self, network: int, tx_hash: str, *, timeout_s: float = 120.0, + poll_s: float = 1.0) -> dict[str, Any] | None: + """The transaction's receipt once mined, or `None` after `timeout_s`.""" + loop = asyncio.get_running_loop() + stop = loop.time() + timeout_s + while True: + receipt = await self._rpc(network, "eth_getTransactionReceipt", [tx_hash]) + if receipt: + return receipt + if loop.time() >= stop: + return None + await asyncio.sleep(poll_s) + + async def amm_allowance(self, market_id: str) -> Decimal: + """What the network's pool contract may spend of an AMM market's + token from this wallet, in tokens.""" + ref, raw = await self._amm_market(market_id) + token = raw.get("token") or {} + address = self._need_address() + result = await self._eth_call(ref.network, str(token.get("address")), sig.allowance_calldata( + address, sig.AMM_CONTRACTS[ref.network])) + return Decimal(int(result, 16)) / (Decimal(10) ** int(token.get("decimals") or 18)) + + async def approve_amm(self, market_id: str, amount: Decimal | None = None, *, wait: bool = True, + timeout_s: float = 120.0) -> dict[str, Any]: + """Approve an AMM market's token to its network's pool contract, once + per token and network: unlimited by default, or `amount` tokens. One + transaction from the wallet, which pays its gas.""" + ref, raw = await self._amm_market(market_id) + token = raw.get("token") or {} + value = MAX_UINT256 if amount is None else _units(D(amount), int(token.get("decimals") or 18), ROUND_CEILING) + contract = sig.AMM_CONTRACTS[ref.network] + tx_hash = await self._send(ref.network, str(token.get("address")), sig.approve_calldata(contract, value), + what="the approval") + if not wait: + return {"transaction_hash": tx_hash} + receipt = await self._receipt(ref.network, tx_hash, timeout_s=timeout_s) + if receipt is None: + raise ExchangeError(f"{VENUE}: approval {tx_hash} unmined after {timeout_s}s; it may yet confirm") + if int(str(receipt.get("status") or "0x0"), 16) != 1: + raise OrderRejected(f"{VENUE}: approval {tx_hash} reverted", reason="reverted", info=receipt) + return {"transaction_hash": tx_hash, "receipt": receipt} + + async def redeem_amm(self, market_id: str, *, wait: bool = True, timeout_s: float = 120.0) -> dict[str, Any]: + """Claim a resolved AMM market's payout: the winning outcome's shares + (`claimWinnings`), or, on a voided market, each outcome held + (`claimVoidedOutcomeShares`). One transaction each from the wallet.""" + ref, raw = await self._amm_market(market_id) + if str(raw.get("state") or "") != "resolved": + raise InvalidOrder(f"{VENUE}: {ref.native} is {raw.get('state')}; only a resolved market pays out") + contract = sig.AMM_CONTRACTS[ref.network] + held = sig.decode_holdings(await self._eth_call(ref.network, contract, sig.amm_holdings_calldata( + ref.market, self._need_address()))) + if raw.get("voided"): + calls = [sig.amm_claim_voided_calldata(ref.market, k) for k, n in enumerate(held) if n > 0] + else: + winner = int(raw.get("resolvedOutcomeId") if raw.get("resolvedOutcomeId") is not None else -1) + calls = [sig.amm_claim_calldata(ref.market)] if 0 <= winner < len(held) and held[winner] > 0 else [] + if not calls: + raise InvalidOrder(f"{VENUE}: the wallet holds nothing on {ref.native} to claim") + hashes = [await self._send(ref.network, contract, data, what="the claim") for data in calls] + if not wait: + return {"transaction_hashes": hashes} + receipts = [] + for tx_hash in hashes: + receipt = await self._receipt(ref.network, tx_hash, timeout_s=timeout_s) + if receipt is None: + raise ExchangeError(f"{VENUE}: claim {tx_hash} unmined after {timeout_s}s; it may yet confirm") + if int(str(receipt.get("status") or "0x0"), 16) != 1: + raise OrderRejected(f"{VENUE}: claim {tx_hash} reverted", reason="reverted", info=receipt) + receipts.append(receipt) + scale = Decimal(10) ** int((raw.get("token") or {}).get("decimals") or 18) + paid = sum((Decimal(int(a["value"])) / scale for r in receipts + for a in sig.market_actions(r, contract=contract, user=self.address)), Decimal("0")) + return {"transaction_hashes": hashes, "receipts": receipts, "paid": paid} + # -- on-chain helpers ----------------------------------------------------- def approval_calls(self) -> list[dict[str, str]]: diff --git a/synpath/trading/myriad_signing.py b/synpath/trading/myriad_signing.py index 153b2d2..7b522e8 100644 --- a/synpath/trading/myriad_signing.py +++ b/synpath/trading/myriad_signing.py @@ -16,6 +16,15 @@ as `x-api-key`, `x-api-timestamp` and `x-api-signature`. The venue refuses a bare key. Without a key the wallet signs in (SIWE): the venue's login message, signed as a personal message, buys a short-lived session token. + +**AMM markets** trade on the network's `PredictionMarket` contract, one call +per trade: `buy(marketId, outcomeId, minOutcomeSharesToBuy, value)` spends +`value` of the market's token (fees come out of it) for at least the minimum +shares, and `sell(marketId, outcomeId, value, maxOutcomeSharesToSell)` pays +out exactly `value` for at most the maximum shares. Amounts are in the +token's smallest unit, and shares count in the same unit. The selectors are +those in the venue's quote calldata and in the deployed contracts' code; each +trade logs a `MarketActionTx`. """ from __future__ import annotations @@ -29,7 +38,7 @@ import httpx from .errors import InvalidOrder -from .polymarket_signing import WalletSigner, _eth, approve_calldata, set_approval_for_all_calldata +from .polymarket_signing import WalletSigner, _call, _eth, approve_calldata, set_approval_for_all_calldata from .predict_fun_signing import _domain_separator, _personal_digest DOMAIN_NAME = "MyriadCTFExchange" @@ -297,3 +306,107 @@ def approval_calls(collateral: str = USD1) -> list[dict[str, str]]: {"to": collateral, "data": approve_calldata(EXCHANGE)}, {"to": CONDITIONAL_TOKENS, "data": set_approval_for_all_calldata(EXCHANGE)}, ] + + +# --------------------------------------------------------------------------- +# AMM markets +# --------------------------------------------------------------------------- + +AMM_CONTRACTS: dict[int, str] = { + 56: "0x39E66eE6b2ddaf4DEfDEd3038E0162180dbeF340", + 2741: "0x3e0F5F8F5Fb043aBFA475C0308417Bf72c463289", + 59144: "0x39E66eE6b2ddaf4DEfDEd3038E0162180dbeF340", +} +"""The `PredictionMarket` contract per network (BNB Chain, Abstract, Linea), +from the venue's contract list.""" + +AMM_RPC_URLS: dict[int, str] = { + 56: "https://bsc-dataseed.bnbchain.org", + 2741: "https://api.mainnet.abs.xyz", + 59144: "https://rpc.linea.build", +} +"""Public nodes, used when no other is given.""" + +MARKET_ACTION_TX = "0x9dcabe311735ed0d65f0c22c5425d1f17331f94c9d0767f59e58473cf95ada61" +"""`MarketActionTx(address indexed user, uint8 indexed action, uint256 indexed +marketId, uint256 outcomeId, uint256 shares, uint256 value, uint256 timestamp)`.""" + +AMM_BUY, AMM_SELL = 0, 1 +"""The `action` of a `MarketActionTx` for a buy and a sell.""" + + +def amm_buy_calldata(market_id: int, outcome: int, min_shares: int, value: int) -> str: + return _call("buy(uint256,uint256,uint256,uint256)", ["uint256"] * 4, + [int(market_id), int(outcome), int(min_shares), int(value)]) + + +def amm_sell_calldata(market_id: int, outcome: int, value: int, max_shares: int) -> str: + return _call("sell(uint256,uint256,uint256,uint256)", ["uint256"] * 4, + [int(market_id), int(outcome), int(value), int(max_shares)]) + + +def amm_claim_calldata(market_id: int) -> str: + """`claimWinnings(marketId)`: a resolved market's winning shares paid out.""" + return _call("claimWinnings(uint256)", ["uint256"], [int(market_id)]) + + +def amm_claim_voided_calldata(market_id: int, outcome: int) -> str: + """`claimVoidedOutcomeShares(marketId, outcomeId)`: one outcome's shares of a voided market.""" + return _call("claimVoidedOutcomeShares(uint256,uint256)", ["uint256", "uint256"], [int(market_id), int(outcome)]) + + +def amm_pools_calldata(market_id: int) -> str: + """`getMarketOutcomesShares(marketId)`: the pool's shares per outcome.""" + return _call("getMarketOutcomesShares(uint256)", ["uint256"], [int(market_id)]) + + +def amm_holdings_calldata(market_id: int, user: str) -> str: + """`getUserMarketShares(marketId, user)`: `(liquidity, shares per outcome)`.""" + _, _, _, _, to_checksum_address = _eth() + return _call("getUserMarketShares(uint256,address)", ["uint256", "address"], [int(market_id), to_checksum_address(user)]) + + +def balance_calldata(owner: str) -> str: + _, _, _, _, to_checksum_address = _eth() + return _call("balanceOf(address)", ["address"], [to_checksum_address(owner)]) + + +def allowance_calldata(owner: str, spender: str) -> str: + _, _, _, _, to_checksum_address = _eth() + return _call("allowance(address,address)", ["address", "address"], + [to_checksum_address(owner), to_checksum_address(spender)]) + + +def _words(result: str) -> list[int]: + raw = str(result or "0x").removeprefix("0x") + return [int(raw[i:i + 64], 16) for i in range(0, len(raw) - len(raw) % 64, 64)] + + +def decode_uint_array(result: str, *, offset_word: int = 0) -> list[int]: + """A `uint256[]` return value, its offset in word `offset_word`.""" + words = _words(result) + start = words[offset_word] // 32 + return words[start + 1:start + 1 + words[start]] + + +def decode_holdings(result: str) -> list[int]: + """`getUserMarketShares`'s shares per outcome (after the liquidity word).""" + return decode_uint_array(result, offset_word=1) + + +def market_actions(receipt: dict[str, Any], *, contract: str, user: str | None = None) -> list[dict[str, int | str]]: + """The `MarketActionTx` logs in a receipt from `contract`, the user's + when given: `{user, action, market_id, outcome, shares, value, timestamp}`, + amounts in the token's smallest unit.""" + out: list[dict[str, int | str]] = [] + for log in receipt.get("logs") or []: + topics = [str(t).lower() for t in log.get("topics") or []] + if str(log.get("address") or "").lower() != contract.lower() or len(topics) < 4 or topics[0] != MARKET_ACTION_TX: + continue + who = "0x" + topics[1][-40:] + if user is not None and who.lower() != user.lower(): + continue + outcome, shares, value, stamp = (_words(log.get("data") or "0x") + [0, 0, 0, 0])[:4] + out.append({"user": who, "action": int(topics[2], 16), "market_id": int(topics[3], 16), "outcome": outcome, + "shares": shares, "value": value, "timestamp": stamp}) + return out diff --git a/tests/samples/myriad_quote_abstract_buy.json b/tests/samples/myriad_quote_abstract_buy.json new file mode 100644 index 0000000..5c14e66 --- /dev/null +++ b/tests/samples/myriad_quote_abstract_buy.json @@ -0,0 +1 @@ +{"value":10,"shares":26.215168002574792,"shares_threshold":25.690864642523294,"price_average":0.38145855098154713,"price_before":0.36838541,"price_after":0.3716471430516322,"calldata":"0x1281311d0000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000018802f10000000000000000000000000000000000000000000000000000000000989680","net_amount":9.7,"fees":{"treasury":0.1,"distributor":0.1,"fee":0.1}} \ No newline at end of file diff --git a/tests/samples/myriad_quote_abstract_sell.json b/tests/samples/myriad_quote_abstract_sell.json new file mode 100644 index 0000000..d09a67a --- /dev/null +++ b/tests/samples/myriad_quote_abstract_sell.json @@ -0,0 +1 @@ +{"value":1.8403765902850164,"shares":5,"shares_threshold":1.803569058479316,"price_average":0.3680753180570033,"price_before":0.36838541,"price_after":0.3677653337699941,"calldata":"0x3620875e0000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000001c14f900000000000000000000000000000000000000000000000000000000004dd1e0","net_amount":1.8403765902850164,"fees":{"treasury":0,"distributor":0,"fee":0}} \ No newline at end of file diff --git a/tests/test_myriad_amm_trading.py b/tests/test_myriad_amm_trading.py new file mode 100644 index 0000000..9235448 --- /dev/null +++ b/tests/test_myriad_amm_trading.py @@ -0,0 +1,531 @@ +"""Myriad AMM orders: the call encoding against the venue's own quote +calldata, the pool maths against its quotes, the slippage bound, and the +adapter against a scripted chain (nothing is sent anywhere).""" +from __future__ import annotations + +import asyncio +import copy +import json +import math +from decimal import Decimal + +import httpx +import pytest +from eth_abi import decode as abi_decode +from eth_abi import encode as abi_encode +from eth_account import Account as EthAccount +from eth_account.typed_transactions import TypedTransaction +from hexbytes import HexBytes + +from synpath.myriad import parse_id +from synpath.trading import myriad_signing as sig +from synpath.trading.credentials import MyriadCredentials +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderRejected +from synpath.trading.myriad import ( + MyriadTrading, amm_buy_cost, amm_buy_shares, amm_leg, amm_sell_shares, amm_sell_value, amm_time_in_force, + fill_of_action, plan_amm_trade, positions_of, +) +from synpath.trading.types import OrderRequest, OrderStatus, OrderType, Side, TimeInForce + +from conftest import load + +KEY = "0x" + "11" * 32 +OWNER = EthAccount.from_key(KEY) +RAW = load("myriad_market_amm.json") +"""Market 120473 on BNB Chain: two outcomes, no fees, USD1.""" +POOLS = [o["shares"] for o in RAW["outcomes"]] +MARKET_ID = "myriad:56-amm-120473" +CONTRACT = sig.AMM_CONTRACTS[56] + +ABSTRACT_POOLS = [1381.597397, 2368.81549] +"""Market 768 on Abstract (USDC.e, 6 decimals; 3% on buys), read with the two quotes below.""" + + +def request(**changes) -> OrderRequest: + base = {"market_id": MARKET_ID, "side": Side.BUY, "quantity": Decimal("10"), "price": Decimal("0.7"), + "type": OrderType.MARKET} + return OrderRequest(**{**base, **changes}) + + +def args_of(calldata: str) -> list[int]: + return list(abi_decode(["uint256"] * 4, bytes.fromhex(calldata[10:]))) + + +# --------------------------------------------------------------------------- +# Encoding and maths, against the venue's quotes +# --------------------------------------------------------------------------- + +class TestCalldata: + @pytest.mark.parametrize("name", ["myriad_quote_buy.json", "myriad_quote_abstract_buy.json"]) + def test_a_buy_is_the_call_the_venue_quotes(self, name): + quote = load(name) + market, outcome, min_shares, value = args_of(quote["calldata"]) + assert sig.amm_buy_calldata(market, outcome, min_shares, value) == quote["calldata"] + assert quote["calldata"].startswith("0x1281311d"), "buy(uint256,uint256,uint256,uint256)" + + @pytest.mark.parametrize("name", ["myriad_quote_sell.json", "myriad_quote_abstract_sell.json"]) + def test_a_sell_is_the_call_the_venue_quotes(self, name): + quote = load(name) + market, outcome, value, max_shares = args_of(quote["calldata"]) + assert sig.amm_sell_calldata(market, outcome, value, max_shares) == quote["calldata"] + assert quote["calldata"].startswith("0x3620875e"), "sell(uint256,uint256,uint256,uint256)" + + def test_the_quote_bounds_the_shares_not_the_value(self): + """The quote's slippage is already in its calldata: minimum shares on a + buy, maximum shares on a sell, the value exact. The token's decimals + count the shares too (6 on Abstract's USDC.e).""" + buy, sell = load("myriad_quote_abstract_buy.json"), load("myriad_quote_abstract_sell.json") + _, outcome, min_shares, value = args_of(buy["calldata"]) + assert (outcome, value) == (1, 10 * 10**6) and min_shares == round(buy["shares_threshold"] * 10**6) + _, _, value, max_shares = args_of(sell["calldata"]) + assert value == round(sell["value"] * 10**6) and max_shares == round(5 * 1.02 * 10**6) + + def test_claims_and_reads(self): + assert sig.amm_claim_calldata(120473) == "0x677bd9ff" + abi_encode(["uint256"], [120473]).hex() + assert sig.amm_claim_voided_calldata(7, 1).startswith("0xec93f0f5") + assert sig.amm_pools_calldata(1).startswith("0xbb986173") + assert sig.amm_holdings_calldata(1, OWNER.address).startswith("0xb31eb895") + array = "0x" + abi_encode(["uint256[]"], [[5, 7]]).hex() + assert sig.decode_uint_array(array) == [5, 7] + assert sig.decode_holdings("0x" + abi_encode(["uint256", "uint256[]"], [9, [3, 0]]).hex()) == [3, 0] + + +class TestPoolMaths: + def test_buys_match_the_venues_quotes(self): + assert amm_buy_shares(POOLS, 0, 25, 0.0) == pytest.approx(load("myriad_quote_buy.json")["shares"], rel=1e-9) + # 3% on buys (fee, treasury, distributor), out of the value spent. + assert amm_buy_shares(ABSTRACT_POOLS, 1, 10, 0.03) == pytest.approx( + load("myriad_quote_abstract_buy.json")["shares"], rel=1e-12) + + def test_sells_match_the_venues_quotes(self): + assert amm_sell_value(POOLS, 0, 20, 0.0) == pytest.approx(load("myriad_quote_sell.json")["value"], rel=1e-9) + assert amm_sell_value(ABSTRACT_POOLS, 1, 5, 0.0) == pytest.approx( + load("myriad_quote_abstract_sell.json")["value"], rel=1e-9) + + def test_inverses(self): + for fee in (0.0, 0.02): + assert amm_buy_shares(POOLS, 1, amm_buy_cost(POOLS, 1, 40, fee), fee) == pytest.approx(40, rel=1e-12) + assert amm_sell_shares(POOLS, 0, amm_sell_value(POOLS, 0, 40, fee), fee) == pytest.approx(40, rel=1e-12) + + def test_a_sell_fee_is_on_top_of_the_payout(self): + """With a fee the pool pays out `value / (1 - fee)`, so the same shares fetch less.""" + assert amm_sell_value(POOLS, 0, 20, 0.01) < amm_sell_value(POOLS, 0, 20, 0.0) + assert amm_sell_shares(POOLS, 0, 9.9, 0.01) == pytest.approx(amm_sell_shares(POOLS, 0, 10, 0.0), rel=1e-12) + + +class TestSlippageBound: + def test_a_buy_spends_for_at_least_value_over_the_worst_price(self): + plan = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("10"), worst=Decimal("0.7"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert plan["bound_units"] == math.ceil(Decimal(plan["value_units"]) / Decimal("0.7")) + assert plan["shares"] == pytest.approx(Decimal("10"), rel=Decimal("1e-9")) + assert plan["average"] < Decimal("0.7") and plan["bound_units"] <= plan["shares"] * 10**18 + + def test_a_sell_is_paid_for_at_most_value_over_the_worst_price(self): + plan = plan_amm_trade(POOLS, index=0, action="sell", shares=Decimal("10"), worst=Decimal("0.6"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert plan["bound_units"] == int(Decimal(plan["value_units"]) / Decimal("0.6")) + assert plan["bound_units"] >= plan["shares"] * 10**18 and plan["average"] > Decimal("0.6") + held = plan_amm_trade(POOLS, index=0, action="sell", shares=Decimal("10"), worst=Decimal("0.6"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True, held=Decimal("10.5")) + assert held["bound_units"] == 105 * 10**17, "never more than the wallet holds" + + def test_known_answer(self): + """Fixed pool, fixed order: the call may never drift.""" + plan = plan_amm_trade(ABSTRACT_POOLS, index=1, action="buy", shares=Decimal("20"), worst=Decimal("0.45"), + buy_fee=0.03, sell_fee=0.0, decimals=6, partial=False) + assert (plan["value_units"], plan["bound_units"]) == (7621195, 16935989) + assert sig.amm_buy_calldata(768, 1, plan["bound_units"], plan["value_units"]) == ( + "0x1281311d" + abi_encode(["uint256"] * 4, [768, 1, 16935989, 7621195]).hex()) + assert plan["fee"] == Decimal("0.22863585") + + def test_ioc_trades_what_fits_within_the_worst_price(self): + whole = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("500"), worst=Decimal("0.7"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=False) + assert "reason" in whole and "worse than 0.7" in whole["reason"] + part = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("500"), worst=Decimal("0.7"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert Decimal("0") < part["shares"] < Decimal("500") + assert part["average"] == pytest.approx(Decimal("0.7"), rel=Decimal("1e-5")) and part["average"] <= Decimal("0.7") + assert part["bound_units"] <= part["shares"] * 10**18, "the bound holds at the pool as read" + + def test_nothing_fits(self): + plan = plan_amm_trade(POOLS, index=0, action="buy", shares=Decimal("5"), worst=Decimal("0.5"), + buy_fee=0.0, sell_fee=0.0, decimals=18, partial=True) + assert plan == {"reason": "the pool fills nothing at 0.5 or better"} + + +class TestOrderShape: + @pytest.mark.parametrize("changes, partial", [ + ({}, True), + ({"type": OrderType.LIMIT, "time_in_force": TimeInForce.IOC}, True), + ({"type": OrderType.LIMIT, "time_in_force": TimeInForce.FOK}, False), + ({"time_in_force": TimeInForce.FOK}, False), + ]) + def test_market_ioc_and_fok(self, changes, partial): + assert amm_time_in_force(request(**changes)) is partial + + @pytest.mark.parametrize("changes, match", [ + ({"type": OrderType.LIMIT}, "taker-only"), + ({"type": OrderType.LIMIT, "time_in_force": TimeInForce.GTD, "expires_at": 1}, "taker-only"), + ({"type": OrderType.LIMIT, "post_only": True}, "post-only"), + ({"price": None}, "worst"), + ({"price": Decimal("1")}, "outside"), + ]) + def test_resting_orders_are_refused(self, changes, match): + with pytest.raises(InvalidOrder, match=match): + amm_time_in_force(request(**changes)) + + def test_two_outcomes_are_yes_and_no(self): + ref = parse_id("56-amm-120473") + assert amm_leg(request(), ref, 2) == (0, "buy", Decimal("0.7")) + assert amm_leg(request(side=Side.SELL), ref, 2) == (1, "buy", Decimal("0.3")) + assert amm_leg(request(side=Side.SELL, reduce_only=True), ref, 2) == (0, "sell", Decimal("0.7")) + assert amm_leg(request(side=Side.BUY, reduce_only=True), ref, 2) == (1, "sell", Decimal("0.3")) + with pytest.raises(InvalidOrder, match="3 outcomes"): + amm_leg(request(), ref, 3) + + def test_one_outcome_of_many(self): + ref = parse_id("2741-amm-412-2") + assert amm_leg(request(), ref, 4) == (2, "buy", Decimal("0.7")) + assert amm_leg(request(side=Side.SELL, reduce_only=True), ref, 4) == (2, "sell", Decimal("0.7")) + with pytest.raises(InvalidOrder, match="rest of the pool"): + amm_leg(request(side=Side.SELL), ref, 4) + with pytest.raises(InvalidOrder, match="names outcome 2"): + amm_leg(request(), ref, 2) + + +# --------------------------------------------------------------------------- +# The adapter against a scripted chain +# --------------------------------------------------------------------------- + +def word(n: int) -> str: + return "0x" + n.to_bytes(32, "big").hex() + + +class Chain: + """Answers the node's calls for one pool and the wallet, decodes and + keeps every signed transaction, and mines it with a scripted receipt.""" + + def __init__(self, *, pools=POOLS, decimals=18, balance=10**24, allowance=2**256 - 1, held=(0, 0), + estimate_error=None, receipt=None): + self.scale = 10**decimals + self.pools = [int(Decimal(str(p)) * self.scale) for p in pools] + self.balance, self.allowance, self.held = balance, allowance, list(held) + self.estimate_error = estimate_error + self.receipt = receipt + self.sent: list[dict] = [] + self.calls: list[dict] = [] + self.mined = True + + def __call__(self, req: httpx.Request) -> httpx.Response: + body = json.loads(req.content) + method, params = body["method"], body["params"] + result = self.answer(method, params) + if isinstance(result, dict) and "error" in result: + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, **result}) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": result}) + + def answer(self, method, params): + if method == "eth_call": + call = params[0] + self.calls.append(call) + selector = call["data"][:10] + if selector == "0xbb986173": + return "0x" + abi_encode(["uint256[]"], [self.pools]).hex() + if selector == "0xb31eb895": + return "0x" + abi_encode(["uint256", "uint256[]"], [0, self.held]).hex() + if selector == "0x70a08231": + return word(self.balance) + if selector == "0xdd62ed3e": + return word(self.allowance) + raise AssertionError(f"unexpected call {selector}") + if method == "eth_getTransactionCount": + return hex(5) + if method in ("eth_gasPrice", "eth_maxPriorityFeePerGas"): + return hex(10**8) + if method == "eth_estimateGas": + if self.estimate_error: + return {"error": {"code": 3, "message": self.estimate_error}} + return hex(200_000) + if method == "eth_sendRawTransaction": + raw = HexBytes(params[0]) + tx = TypedTransaction.from_bytes(raw).as_dict() + tx["from"] = EthAccount.recover_transaction(raw) + self.sent.append(tx) + return "0x" + "ab" * 32 + if method == "eth_getTransactionReceipt": + if not self.mined: + return None + return self.receipt(self.sent[-1]) if self.receipt else {"status": "0x1", "logs": []} + raise AssertionError(f"unexpected rpc {method}") + + +def action_log(*, action: int, market: int, outcome: int, shares: int, value: int, user=OWNER.address, + contract=CONTRACT, timestamp=1792900000) -> dict: + return { + "address": contract.lower(), + "topics": [sig.MARKET_ACTION_TX, "0x" + "00" * 12 + user[2:].lower(), word(action), word(market)], + "data": "0x" + abi_encode(["uint256"] * 4, [outcome, shares, value, timestamp]).hex(), + } + + +def filled_as_planned(shares_factor=Decimal("1")): + """A receipt that fills what the call asked: the value exact, the shares + the pool gives (a buy) or the shares a sell burns.""" + def receipt(tx): + data = "0x" + bytes(tx["data"]).hex() + market, outcome, a, b = args_of(data) + buying = data.startswith("0x1281311d") + value = b if buying else a + pools = [p / 10**18 for p in Chain().pools] + shares = (amm_buy_shares(pools, outcome, value / 10**18, 0.0) if buying + else amm_sell_shares(pools, outcome, value / 10**18, 0.0)) + units = int(Decimal(str(shares)) * shares_factor * 10**18) + return {"status": "0x1", "blockNumber": "0x10", "gasUsed": "0x30d40", "logs": [ + action_log(action=0 if buying else 1, market=market, outcome=outcome, shares=units, value=value)]} + return receipt + + +class Catalog: + def __init__(self, raw=RAW): + self.raw = raw + + def _resolve(self, market_id): + return parse_id(market_id), None + + def raw_market(self, market_id): + return self.raw + + def fetch_fee_schedule(self, market_id): # pragma: no cover - unused here + raise AssertionError + + def close(self): + pass + + +def adapter(chain: Chain, *, raw=RAW, venue=None) -> MyriadTrading: + creds = MyriadCredentials(private_key=KEY, api_key="key-1", api_secret="secret-1") + return MyriadTrading( + creds, catalog=Catalog(raw), + client=httpx.AsyncClient(transport=httpx.MockTransport(venue or (lambda r: httpx.Response(404)))), + rpc_client=httpx.AsyncClient(transport=httpx.MockTransport(chain)), + ) + + +def run(coro): + return asyncio.run(coro) + + +async def trade(chain: Chain, req: OrderRequest, *, raw=RAW): + async with adapter(chain, raw=raw) as myriad: + return await myriad.create_order(req) + + +class TestAdapter: + def test_a_buy_is_one_signed_call_and_its_fill_comes_from_the_receipt(self): + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(client_order_id="c-1"))) + [tx] = chain.sent + assert tx["from"] == OWNER.address and tx["to"] == HexBytes(CONTRACT) and tx["chainId"] == 56 + assert tx["nonce"] == 5 and tx["gas"] == 240_000 and tx["value"] == 0 + market, outcome, min_shares, value = args_of("0x" + bytes(tx["data"]).hex()) + assert (market, outcome) == (120473, 0) and min_shares == math.ceil(Decimal(value) / Decimal("0.7")) + assert {c["data"][:10] for c in chain.calls} == {"0xbb986173", "0x70a08231", "0xdd62ed3e"} + assert order.id == "0x" + "ab" * 32 and order.status == OrderStatus.CLOSED + assert order.market_id == MARKET_ID and order.client_order_id == "c-1" + assert order.filled == pytest.approx(Decimal("10"), rel=Decimal("1e-9")) + assert order.average_price == pytest.approx(Decimal(value) / 10**18 / order.filled) + assert order.average_price < Decimal("0.7") and order.fee == 0 and order.fee_currency == "USD1" + [fill] = order.info["fills"] + assert fill.id == f"{order.id}:0:buy" and fill.order_id == order.id and fill.side == Side.BUY + + def test_buying_no_buys_outcome_one(self): + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(side=Side.SELL, price=Decimal("0.6")))) + market, outcome, min_shares, value = args_of("0x" + bytes(chain.sent[0]["data"]).hex()) + assert outcome == 1 and min_shares == math.ceil(Decimal(value) / Decimal("0.4")) + assert order.side == Side.SELL and order.average_price > Decimal("0.6"), "the YES price of a NO buy" + assert order.info["fills"][0].side == Side.SELL + + def test_a_sell_checks_the_shares_held_and_bounds_what_it_burns(self): + chain = Chain(held=[15 * 10**18, 0], receipt=filled_as_planned()) + order = run(trade(chain, request(side=Side.SELL, reduce_only=True, price=Decimal("0.6")))) + market, outcome, value, max_shares = args_of("0x" + bytes(chain.sent[0]["data"]).hex()) + assert chain.sent[0]["data"][:4] == bytes.fromhex("3620875e") and outcome == 0 + assert max_shares == min(int(Decimal(value) / Decimal("0.6")), 15 * 10**18) + assert order.status == OrderStatus.CLOSED and order.average_price > Decimal("0.6") + assert order.info["fills"][0].id.endswith(":0:sell") + with pytest.raises(InsufficientFunds, match="holds 5"): + run(trade(Chain(held=[5 * 10**18, 0]), request(side=Side.SELL, reduce_only=True, price=Decimal("0.6")))) + + def test_fees_are_reported(self): + raw = copy.deepcopy(RAW) + raw["fees"]["buy"] = {"fee": 0.01, "treasury_fee": 0, "distributor_fee": 0.01} + raw["fees"]["sell"] = {"fee": 0.01, "treasury_fee": 0, "distributor_fee": 0} + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(), raw=raw)) + value = Decimal(args_of("0x" + bytes(chain.sent[0]["data"]).hex())[3]) / 10**18 + assert order.fee == value * Decimal("0.02") and order.info["plan"]["fee"] == str(value * Decimal("0.02")) + chain = Chain(held=[15 * 10**18, 0], receipt=filled_as_planned()) + order = run(trade(chain, request(side=Side.SELL, reduce_only=True, price=Decimal("0.6")), raw=raw)) + paid = Decimal(args_of("0x" + bytes(chain.sent[0]["data"]).hex())[2]) / 10**18 + assert order.fee == pytest.approx(paid / Decimal("0.99") - paid) + + def test_without_approval_nothing_is_sent(self): + chain = Chain(allowance=0) + with pytest.raises(InsufficientFunds, match="approve_amm"): + run(trade(chain, request())) + with pytest.raises(InsufficientFunds, match="holds"): + run(trade(Chain(balance=10**18), request())) + assert chain.sent == [] + + def test_a_simulated_revert_is_refused_before_sending(self): + chain = Chain(estimate_error="execution reverted: minimum shares not met") + with pytest.raises(OrderRejected, match="would revert") as caught: + run(trade(chain, request())) + assert caught.value.reason == "simulation" and chain.sent == [] + + def test_a_reverted_trade_is_rejected(self): + chain = Chain(receipt=lambda tx: {"status": "0x0", "logs": []}) + order = run(trade(chain, request())) + assert order.status == OrderStatus.REJECTED and order.filled == 0 and "reverted" in order.info["reason"] + + def test_nothing_within_the_worst_price_sends_nothing(self): + chain = Chain() + order = run(trade(chain, request(price=Decimal("0.5")))) + assert order.status == OrderStatus.CANCELED and order.filled == 0 and chain.sent == [] + assert "nothing" in order.info["reason"] + assert run(trade(Chain(), request(quantity=Decimal("500"), time_in_force=TimeInForce.FOK))).status == \ + OrderStatus.CANCELED + + def test_ioc_fills_part(self): + chain = Chain(receipt=filled_as_planned()) + order = run(trade(chain, request(quantity=Decimal("500")))) + assert order.status == OrderStatus.CANCELED and Decimal("0") < order.filled < Decimal("500") + assert order.average_price <= Decimal("0.7") + + def test_a_pending_trade_is_read_again(self): + chain = Chain(receipt=filled_as_planned()) + chain.mined = False + + async def go(): + async with adapter(chain) as myriad: + order = await myriad.create_order(request(params={"wait": False})) + opened = await myriad.fetch_open_orders(market_id=MARKET_ID) + with pytest.raises(InvalidOrder, match="nothing to cancel"): + await myriad.cancel_order(order.id) + chain.mined = True + return order, opened, await myriad.fetch_order(order.id), await myriad.fetch_orders(market_id=MARKET_ID) + + pending, opened, done, page = run(go()) + assert pending.status == OrderStatus.PENDING and [o.id for o in opened] == [pending.id] + assert done.status == OrderStatus.CLOSED and done.filled > 0 and [o.status for o in page] == [OrderStatus.CLOSED] + + def test_a_closed_market_is_refused(self): + raw = {**RAW, "state": "closed"} + with pytest.raises(InvalidOrder, match="closed"): + run(trade(Chain(), request(), raw=raw)) + + def test_a_resting_order_is_refused_before_any_read(self): + chain = Chain() + with pytest.raises(InvalidOrder, match="taker-only"): + run(trade(chain, request(type=OrderType.LIMIT))) + assert chain.calls == [] + + def test_approve_and_redeem(self): + resolved = {**RAW, "state": "resolved", "resolvedOutcomeId": 1, "voided": False} + chain = Chain(held=[0, 4 * 10**18], receipt=lambda tx: {"status": "0x1", "logs": [ + action_log(action=4, market=120473, outcome=1, shares=4 * 10**18, value=4 * 10**18)]}) + + async def go(): + async with adapter(chain, raw=resolved) as myriad: + approved = await myriad.approve_amm(MARKET_ID) + redeemed = await myriad.redeem_amm(MARKET_ID) + return approved, redeemed + + approved, redeemed = run(go()) + approve, claim = chain.sent + assert approve["to"] == HexBytes(RAW["token"]["address"]) + assert "0x" + bytes(approve["data"]).hex() == sig.approve_calldata(CONTRACT) + assert claim["to"] == HexBytes(CONTRACT) and "0x" + bytes(claim["data"]).hex() == sig.amm_claim_calldata(120473) + assert redeemed["paid"] == Decimal("4") and approved["receipt"]["status"] == "0x1" + voided = {**resolved, "voided": True} + chain = Chain(held=[2, 3]) + + async def void(): + async with adapter(chain, raw=voided) as myriad: + return await myriad.redeem_amm(MARKET_ID, wait=False) + + assert len(run(void())["transaction_hashes"]) == 2 + assert ["0x" + bytes(tx["data"]).hex() for tx in chain.sent] == [ + sig.amm_claim_voided_calldata(120473, 0), sig.amm_claim_voided_calldata(120473, 1)] + + +class TestConsistency: + """The fill an AMM order reports, the account's history of the same trade + and the position it makes all name the same market, and the fill and the + history row the same id.""" + + def test_two_outcomes(self): + order = run(trade(Chain(receipt=filled_as_planned()), request())) + [fill] = order.info["fills"] + row = {"action": "buy", "marketId": 120473, "networkId": 56, "outcomeId": 0, "shares": str(fill.quantity), + "value": fill.info["value"], "timestamp": 1792900000, "txId": order.id} + history = fill_of_action(row, amm=True) + [position] = positions_of([{"marketId": 120473, "networkId": 56, "executionMode": 0, "outcomeId": 0, + "shares": str(fill.quantity), "price": 0.65, "status": "ongoing"}]) + assert history.id == fill.id and history.market_id == fill.market_id == position.market_id == MARKET_ID + assert history.price == fill.price and history.side == fill.side + + def test_one_outcome_of_many(self): + raw = copy.deepcopy(RAW) + raw["id"], raw["networkId"] = 412, 2741 + raw["outcomes"] = [{**raw["outcomes"][0], "id": k} for k in range(3)] + raw["token"] = {**raw["token"], "decimals": 6} + chain = Chain(pools=[1000, 1000, 1000], decimals=6, receipt=lambda tx: {"status": "0x1", "logs": [ + action_log(action=0, market=412, outcome=1, shares=12 * 10**6, value=4 * 10**6, + contract=sig.AMM_CONTRACTS[2741])]}) + order = run(trade(chain, request(market_id="myriad:2741-amm-412-1", quantity=Decimal("12"), + price=Decimal("0.5")), raw=raw)) + assert chain.sent[0]["chainId"] == 2741 and chain.sent[0]["to"] == HexBytes(sig.AMM_CONTRACTS[2741]) + [fill] = order.info["fills"] + assert fill.market_id == order.market_id == "myriad:2741-amm-412-1" + assert (fill.side, fill.quantity, fill.price) == (Side.BUY, Decimal("12"), Decimal("4") / Decimal("12")) + row = {"action": "buy", "marketId": 412, "networkId": 2741, "outcomeId": 1, "shares": "12", "value": "4", + "timestamp": 1792900000, "txId": order.id} + assert fill_of_action(row, amm=True, many=True).market_id == fill.market_id + rows = [{"marketId": 412, "networkId": 2741, "executionMode": 0, "outcomeId": 1, "shares": 12, "status": "ongoing"}] + assert positions_of(rows, many={(2741, 412)})[0].market_id == fill.market_id + assert positions_of(rows)[0].market_id == "myriad:2741-amm-412", "without knowing, outcome 1 reads binary" + + def test_history_and_positions_ask_for_both_kinds(self): + address = OWNER.address + many_raw = {**RAW, "id": 412, "networkId": 2741, "outcomes": [RAW["outcomes"][0]] * 3} + + def events(req): + model = req.url.params["trading_model"] + row = ({"action": "buy", "marketId": 3722, "networkId": 56, "outcomeId": 0, "shares": 2, "value": 1, + "timestamp": 1792772000, "txId": "0xob"} if model == "ob" else + {"action": "sell", "marketId": 412, "networkId": 2741, "outcomeId": 0, "shares": 2, "value": 1, + "timestamp": 1792773000, "txId": "0xamm"}) + return httpx.Response(200, json={"data": [row], "pagination": {"hasNext": model == "amm"}}) + + portfolio = {"data": [{"marketId": 412, "networkId": 2741, "executionMode": 0, "outcomeId": 0, "shares": 2, + "status": "ongoing"}], "pagination": {"hasNext": False}} + + def venue(req): + if req.url.path == f"/users/{address}/events": + return events(req) + if req.url.path == f"/users/{address}/portfolio": + return httpx.Response(200, json=portfolio) + return httpx.Response(404) + + async def go(): + async with adapter(Chain(), raw=many_raw, venue=venue) as myriad: + return await myriad.fetch_my_trades(), await myriad.fetch_positions() + + trades, [position] = run(go()) + assert [(f.id, f.market_id) for f in trades] == [("0xamm:0:sell", "myriad:2741-amm-412-0"), + ("0xob:0:buy", "myriad:56-3722")] + assert trades.next_cursor == "2" and position.market_id == "myriad:2741-amm-412-0" diff --git a/tests/test_myriad_trading.py b/tests/test_myriad_trading.py index 57c6c10..d4224ad 100644 --- a/tests/test_myriad_trading.py +++ b/tests/test_myriad_trading.py @@ -425,14 +425,21 @@ async def go(): with pytest.raises(OrderRejected): run(go()) - def test_an_amm_market_is_refused(self): + def test_an_amm_market_takes_no_resting_order_or_order_book_calldata(self): async def go(): async with adapter(Venue()) as myriad: return await myriad.create_order(request(market_id="myriad:56-amm-120473")) - with pytest.raises(InvalidOrder, match="AMM"): + with pytest.raises(InvalidOrder, match="taker-only"): run(go()) + async def calldata(): + async with adapter(Venue()) as myriad: + return await myriad.position_calldata("redeem", "myriad:56-amm-120473") + + with pytest.raises(InvalidOrder, match="AMM market"): + run(calldata()) + def test_without_a_key_the_wallet_signs_in(self): calls = [] @@ -473,9 +480,10 @@ def test_open_orders_positions_and_fills(self): f"GET /users/{OWNER.address}/portfolio": {"data": [ {"marketId": 3722, "networkId": 56, "executionMode": 1, "outcomeId": 0, "shares": 2, "price": 0.9, "value": 1.8, "profit": 0, "status": "ongoing"}], "pagination": {"hasNext": False}}, - f"GET /users/{OWNER.address}/events": {"data": [ + f"GET /users/{OWNER.address}/events": lambda r: httpx.Response(200, json={"data": [ {"action": "buy", "marketId": 3722, "networkId": 56, "outcomeId": 0, "shares": 2, "value": 1.8, - "timestamp": 1792772000, "txId": "0xt"}], "pagination": {"hasNext": False}}, + "timestamp": 1792772000, "txId": "0xt"}] if r.url.params["trading_model"] == "ob" else [], + "pagination": {"hasNext": False}}), }) async def go(): @@ -488,7 +496,9 @@ async def go(): assert venue.sent("GET", "/orders")[0].url.params["market_id"] == "3722" assert position.market_id == MARKET_ID and position.contracts == Decimal("2") assert [f.id for f in fills] == ["0xt:0:buy"] - assert venue.sent("GET", f"/users/{OWNER.address}/events")[0].url.params["since"] == "1792771000" + ob, amm = venue.sent("GET", f"/users/{OWNER.address}/events") + assert ob.url.params["since"] == "1792771000" and ob.url.params["network_id"] == "56" + assert amm.url.params["trading_model"] == "amm" and "network_id" not in amm.url.params def test_the_balance_is_the_wallets_usd1_less_open_buys(self): entry, _, _ = signed(request(price=Decimal("0.4"))) From 4c2181882cf5e9e112b8c694f93978fc62802433 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 4/8] feat: gemini-combo-rfq Co-Authored-By: Claude Opus 5.5 --- docs/capabilities.md | 14 +- docs/streaming.md | 1 + docs/trading.md | 68 ++++- synpath/__init__.py | 2 +- synpath/trading/gemini.py | 208 ++++++++++++++- synpath/ws/__init__.py | 4 +- synpath/ws/gemini.py | 347 +++++++++++++++++++++++- tests/samples/gemini_combo_rfq.json | 115 ++++++++ tests/test_gemini_combo_rfq.py | 400 ++++++++++++++++++++++++++++ tests/test_gemini_trading.py | 2 +- 10 files changed, 1145 insertions(+), 16 deletions(-) create mode 100644 tests/samples/gemini_combo_rfq.json create mode 100644 tests/test_gemini_combo_rfq.py diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..0c2c3cb 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -496,9 +496,14 @@ WebSocket takes no other kind). The account must have accepted Gemini's current Prediction Markets terms; `GeminiTrading.fetch_terms_status()` says whether it has. See [trading](trading.md#gemini). -Combo RFQ is not offered. It is a sealed one-second auction for market makers -over the WebSocket, in beta, and does not map onto the request-and-accept RFQ -the Kalshi adapter has. +Combos are instruments (`gemini:GEMI-CMB-...`) with their own books, and +`GeminiTrading` lists, looks up and creates them; Gemini's documentation says +the combo routes are not enabled in production yet, where they raise +`CombosUnavailable`. Combo RFQ is a sealed one-second auction over the +WebSocket, in beta: `GeminiRfqStream` is the maker side (watch auctions, +submit, withdraw and confirm quotes). The requester side is in the Gemini +app only, with no API, so the request-and-accept calls the Kalshi adapter +has raise `NotSupported` here. See [trading](trading.md#gemini). Matching spans venues, so no adapter claims it. `synpath.match_market(id)` and `synpath.match_event(id)` are answered by Synpath's hosted matching service @@ -539,7 +544,7 @@ onboarded firms. | `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | | `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | | `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | partial: the maker side of the combo RFQ auction (`GeminiRfqStream`); requesting is app-only | | `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; @@ -586,6 +591,7 @@ Every stream answers the `watch_*` keys the same way | `MyriadUserStream` | no | no | no | no | yes | yes | partial: one outcome's balance per update | no | | `GeminiMarketStream` | yes, differential, from a snapshot; a skipped update resubscribes | yes, top of book and last trade | yes | yes, contract status and strikes | no | no | no | no | | `GeminiUserStream` | no | no | no | no | yes | yes, each execution with its fee | yes, settlement payouts included | yes | +| `GeminiRfqStream` | no | no | no | no | no | no | no | no | The exchange API's other gRPC streams carry one thing each and say so: drop copy and trade capture carry fills, position and position-change carry diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..eed8e71 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -53,6 +53,7 @@ asyncio.run(main()) | `MyriadUserStream` | `ws.myriadprotocol.com/ws` | the wallet's address, and a subscription token per account channel (`MyriadTrading.subscription_token`) | orders; fills from the wallet's public tape; share balances | | `GeminiMarketStream` | `ws.gemini.com` (or the sandbox) | none | differential books in YES space from a snapshot, top of book with the last trade, public prints, contract status and strikes | | `GeminiUserStream` | `ws.gemini.com` | an account-scoped Gemini API key with a time-based nonce (the handshake is signed) | orders; each execution with its fee and maker flag; positions, settlement payouts included; balances | +| `GeminiRfqStream` | `ws.gemini.com` (or the sandbox) | none to watch auctions; the same account key to watch deliveries and quote | combo RFQ auctions as they open and end; this account's deliveries (winning quote, acceptance, outcome), deduplicated; quotes submitted, withdrawn and confirmed ([trading](trading.md#gemini)) | Each stream's `has` answers the `watch_*` capability keys (`watch_order_book`, `watch_ticker`, `watch_trades`, `watch_market_status`, `watch_orders`, diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..eb7691c 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -879,6 +879,68 @@ Fees follow the contract's listing date and are rounded up to the cent; see [Gemini](capabilities.md#gemini). `fetch_fee_estimate` reads the contract's schedule with one public request. -Combo RFQ (Gemini's sealed WebSocket auction for multi-leg combos, in beta -and run by market makers) is not offered, and nor are WebSocket order -placement and `cancelOnDisconnect`. +A combo (two to six contracts, each on a YES or NO outcome, settling YES +only if every leg does) is its own instrument, `gemini:GEMI-CMB-...`, with +its own book, orders and positions: `create_order`, `fetch_orders` and +`fetch_positions` take it as they take any contract. `fetch_combos` and +`fetch_combo` (public) list combos and look one up with its legs, each leg +Gemini's numeric contract id with the outcome the combo needs from it; +any listed contract can be a leg. `create_combo([("101", "yes"), ("202", +"no")])` returns the canonical combo for a set of legs, creating it if +Gemini has none (signed; it places nothing). Gemini's documentation says +these combo routes are not enabled in production yet: there they raise +`CombosUnavailable`, carrying Gemini's answer. The sandbox +(`GEMINI_ENV=sandbox`) has them. + +Combo RFQ is a sealed auction on Gemini's WebSocket, in beta. A requester +opens an RFQ and `requestForQuote` broadcasts it, anonymously, with its legs +and its size in contracts or USD. Makers each send one immutable, +single-sided quote (the price they sell the combo's YES at) within one +second. At the close only the best eligible quote (lowest price, then +earliest) is disclosed, privately, to the requester, who has five seconds to +accept. The winning maker then has one second to confirm or decline (its +last look). The requester side, opening an RFQ and accepting its quote, is +in the Gemini app and website only, and Gemini publishes no API for it, so +`create_rfq` and `accept_quote` raise `NotSupported`. + +The maker side is `synpath.ws.gemini.GeminiRfqStream`: + +```python +from synpath.ws.gemini import GeminiRfqStream + +async with GeminiRfqStream(creds) as stream: # GEMINI_ENV=sandbox for the sandbox + await stream.watch_rfqs() # public, anonymous + await stream.watch_deliveries() # this account's acceptances and outcomes + async for event in stream: + if getattr(event, "name", None) == "rfq" and event.payload["state"] == "OPEN": + await stream.submit_quote(event.payload["rfq_id"], "0.55", "100") + elif getattr(event, "name", None) == "rfq_delivery" and event.payload["transition"] == "ACCEPTED": + await stream.confirm_quote(event.payload["rfq_id"], event.payload["quote_id"], True) +``` + +| Request, quotes, accept | Gemini | +|---|---| +| Ask for a price | the requester opens an RFQ in the Gemini app; no API | +| Quotes arrive | sealed: none while the window is open; at close one `rfq_delivery` with `transition="CLOSED"` and the winning quote (`quote_id`, `price`, `size`), to the requester only | +| Quote expires | five seconds after the close (`PENDING_ACCEPTANCE`), or at the RFQ's `expires_at` if sooner; `valid_until` only decides eligibility at the close | +| Accept | the requester, in the app; no API | +| Maker confirms | `confirm_quote(rfq_id, quote_id, True or False)` within one second of the `ACCEPTED` delivery | +| Fill, position | the RFQ ends `FINALIZED` (`fill_quantity` is the requester's); the trade is an order on the combo's instrument, on `GeminiUserStream` and `fetch_positions` | + +Quotes are checked before sending: a price on the cent tick inside (0, 1), a +quantity on the hundredth-contract grid that must cover the whole request +to win (`quantity * price >= notional` on a USD-sized RFQ), a `client_id` +of at most 36 printable ASCII characters (it comes back as the maker fill's +client order id), and an RFQ the stream saw close is refused. A quote +method is sent only on a live connection and never resent: no answer, or a +connection lost before one, is an unknown outcome to reconcile on the +delivery stream. Gemini's errors come back typed with its code (`-2010`, a +quote it refused, is `OrderRejected`). Deliveries are at least once and are +deduplicated by Gemini's delivery id. The maker's order rests on the book +before the requester's fill-or-kill order arrives, so it can fill in part, +or not at all, whatever the RFQ's outcome: read the maker's fills from +`GeminiUserStream`, not from the RFQ's state. Quoting needs an +account-scoped key on the signed handshake and an account Gemini has made +eligible; `withdraw_quote` also needs the session's `cancel_orders`. + +WebSocket order placement and `cancelOnDisconnect` are not offered. diff --git a/synpath/__init__.py b/synpath/__init__.py index ff88c20..37e878d 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -157,7 +157,7 @@ def exchange(venue: str, **kwargs) -> Exchange: "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", "GeminiRfqStream", ) _SERVER_NAMES = ("create_app", "create_trading_app", "VenueRegistry", "ControlStore", "Principal") diff --git a/synpath/trading/gemini.py b/synpath/trading/gemini.py index 5edfe89..fd5bd92 100644 --- a/synpath/trading/gemini.py +++ b/synpath/trading/gemini.py @@ -33,6 +33,18 @@ **Fills.** The REST API has no fills endpoint. `fetch_my_trades` reads one aggregate fill per order from the order history; the account stream (`synpath.ws.gemini.GeminiUserStream`) delivers each execution. + +**Combos and their RFQ.** A combo (two to six contracts, each on a YES or NO +outcome, settling YES only if every leg does) is its own instrument, +`GEMI-CMB-...`, with its own book: `create_order`, the order history and +`fetch_positions` take it like any contract. `fetch_combos`, `fetch_combo` +and `create_combo` are Gemini's combo routes, which its documentation says +are not enabled in production yet; there they fail with `CombosUnavailable` +carrying Gemini's own answer. Combo RFQ is a sealed one-second auction run +on the WebSocket. Gemini publishes the maker side +(`synpath.ws.gemini.GeminiRfqStream`); the requester side, opening an RFQ +and accepting its winning quote, is in the Gemini app and website only, with +no API, so `create_rfq` and `accept_quote` raise `NotSupported`. """ from __future__ import annotations @@ -47,7 +59,7 @@ from .. import ids from ..base import AsyncHttpClient, Capability -from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, RateLimitExceeded +from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported, RateLimitExceeded from ..gemini import ( API_URL, SANDBOX_URL, candidate_event_tickers, fee_schedule_of, native_symbol, parse_ts, ) @@ -394,6 +406,129 @@ def balance_of(rows: list[dict[str, Any]], *, account: Account, currency: str = ) +# --------------------------------------------------------------------------- +# Combos +# --------------------------------------------------------------------------- + +COMBO_LEGS = (2, 6) +"""Fewest and most legs a combo takes.""" + +COMBO_PAGE = 50 +"""Combos per listing page unless `limit` says otherwise.""" + +RFQ_APP_ONLY = ( + f"{VENUE}: combo RFQs are opened, and their winning quote accepted, in the Gemini app and website; Gemini " + f"publishes no API for the requester side (developer.gemini.com/prediction-markets/combos-rfq/maker-integration). " + f"Trade a combo on its own book with create_order on its GEMI-CMB symbol, or quote RFQs as a maker with " + f"synpath.ws.gemini.GeminiRfqStream" +) + + +class CombosUnavailable(ExchangeError): + """Gemini answered that combos are not available on this host. Its + documentation says the combo routes are not enabled in production yet; + `body` and `status` are Gemini's own answer. The sandbox has them.""" + + +def _outcome_word(value: Any) -> str: + text = str(value or "").strip().lower() + if text not in ("yes", "no"): + raise InvalidOrder(f"{VENUE}: a combo leg's outcome is yes or no, got {value!r}") + return text + + +def _instrument(value: Any) -> str | None: + """A Synpath id for a contract ticker that is an instrument symbol.""" + text = native_symbol(str(value or "")) + return ids.qualify(VENUE, text) if text.startswith("GEMI-") else None + + +def combo_legs_of(legs: list[Any]) -> list[dict[str, str]]: + """`create_combo` legs as the wire's `[{"contractId", "requiredOutcome"}]`. + + A leg is a `(contract_id, outcome)` pair or a dict with `contract_id` (or + the wire's `contractId`) and `outcome` (or `requiredOutcome`). The id is + Gemini's numeric contract id (a combo leg's `contractId`, an RFQ leg's + `c`), not an instrument symbol. Two to six legs, each contract once, + checked before anything is signed.""" + out: list[dict[str, str]] = [] + for leg in legs: + if isinstance(leg, dict): + contract, outcome = leg.get("contract_id", leg.get("contractId")), leg.get("outcome", leg.get("requiredOutcome")) + else: + contract, outcome = leg + contract = str(contract or "").strip() + if not contract.isdigit(): + raise InvalidOrder(f"{VENUE}: a combo leg names Gemini's numeric contract id, got {contract!r}") + out.append({"contractId": contract, "requiredOutcome": _outcome_word(outcome).capitalize()}) + low, high = COMBO_LEGS + if not low <= len(out) <= high: + raise InvalidOrder(f"{VENUE}: a combo takes {low} to {high} legs, got {len(out)}") + if len({leg["contractId"] for leg in out}) != len(out): + raise InvalidOrder(f"{VENUE}: a combo names each contract once") + return out + + +def combo_of(raw: dict[str, Any]) -> dict[str, Any]: + """A combo as one shape, from either of Gemini's: a listing or lookup row + (`{"contract", "legs"}`) or a create answer's `combo` (`ComboSummary`). + + `market_id` is the combo's own instrument (`gemini:GEMI-CMB-...`), `None` + while Gemini has not registered one; each leg carries its `contract_id`, + the `outcome` the combo needs from it (`yes` / `no`), its own `market_id` + where Gemini names its symbol, and `result` once it has resolved.""" + contract = raw.get("contract") if isinstance(raw.get("contract"), dict) else None + if contract is not None: + symbol = contract.get("contractTicker") + combo_id, title, status = contract.get("contractId"), contract.get("contractName"), contract.get("contractStatus") + registered = _instrument(symbol) is not None + expiry = contract.get("expiryDate") + else: + symbol = raw.get("instrumentSymbol") + combo_id, title, status = raw.get("id"), raw.get("displayName"), raw.get("status") + registered = bool(raw.get("instrumentRegistered")) + expiry = raw.get("latestExpiryDate") + legs = [] + for leg in sorted(raw.get("legs") or [], key=lambda row: int(row.get("legIndex") or 0)): + inner = leg.get("contract") if isinstance(leg.get("contract"), dict) else {} + result = leg.get("legOutcome") + legs.append({ + "contract_id": str(leg.get("contractId") or ""), + "outcome": _outcome_word(leg.get("requiredOutcome")), + "market_id": _instrument(inner.get("contractTicker")), + "title": inner.get("contractName"), + "result": str(result).lower() if result else None, + "resolved_at": parse_ts(leg.get("resolvedAt")), + }) + return { + "market_id": _instrument(symbol) if registered or contract is not None else None, + "combo_id": str(combo_id) if combo_id not in (None, "") else None, + "title": title, + "status": str(status).lower() if status else None, + "registered": registered, + "expires_at": parse_ts(expiry), + "legs": legs, + "info": raw, + } + + +def _combo_error(exc: ExchangeError, *, base_url: str) -> ExchangeError: + """Gemini's documented "combos unavailable" answer (a 404 with no code + and the generic `Not Found`, where a missing combo says `Combo not found` + and a missing leg `COMBO_LEG_NOT_FOUND`) as `CombosUnavailable`; anything + else as it came. A 503 stays `ExchangeNotAvailable`: temporary.""" + body = exc.body if isinstance(exc.body, dict) else {} + if not (exc.status == 404 and not body.get("code") and str(body.get("message") or "") == "Not Found"): + return exc + host = "production" if base_url.rstrip("/") == API_URL.rstrip("/") else base_url + said = " ".join(str(part) for part in (exc.status, body.get("error"), body.get("message")) if part) + return CombosUnavailable( + f"{VENUE}: combos are not available on {host} (Gemini answered {said or exc}); Gemini's documentation says " + f"the combo routes are not enabled in production yet; the sandbox (env sandbox) has them", + body=exc.body, status=exc.status, + ) + + def _code(body: Any) -> tuple[str, str]: """(the venue's error code, its message) from Gemini's error payloads: `{"result": "error", "reason", "message"}` (authentication), @@ -480,8 +615,9 @@ class GeminiTrading(TradingExchange): "fetch_settlements": True, "fetch_queue_position": False, "fetch_fee_estimate": True, - # Combo RFQ is a WebSocket auction for market makers, in beta; not offered. - "rfq": False, + # Combo RFQ is a sealed WebSocket auction: the maker side is + # `synpath.ws.gemini.GeminiRfqStream`; requesting is app-only. + "rfq": "partial", "split_merge": False, # The account streams are `synpath.ws.gemini.GeminiUserStream`. "watch_orders": False, @@ -782,6 +918,72 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency="USD", info={"schedule": schedule.model_dump()}, ) + # -- combos and RFQ --------------------------------------------------------- + + async def _combos(self, path: str, *, params: dict[str, Any] | None = None, fields: Any = None, + write: bool = False) -> Any: + try: + if write: + return await self._call(path, fields, kind="write") + return await self._public(path, params) + except ExchangeError as exc: + raise _combo_error(exc, base_url=self.base_url) from None + + async def fetch_combos( + self, *, status: str | None = None, contract_id: str | None = None, + registered: bool | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[dict[str, Any]]: + """Combos (public), `Active` ones unless `status` says otherwise + (`Settled`, `Voided`: Gemini's words), each with its legs (`combo_of`). + `contract_id` keeps those with that contract as a leg; `registered` + those with (or without) an instrument to trade. Pages by offset. + Any listed contract can be a leg: there is no separate eligibility + list. Not enabled in production yet (`CombosUnavailable`).""" + wanted = max(1, limit or COMBO_PAGE) + offset = int(cursor) if cursor and cursor.isdigit() else 0 + if cursor and not cursor.isdigit(): + raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") + params = { + "status": status, "contractId": contract_id, + "instrumentRegistered": None if registered is None else str(registered).lower(), + "limit": wanted, "offset": offset or None, + } + raw = await self._combos(f"{PREFIX}/combos", params={k: v for k, v in params.items() if v is not None}) or {} + rows = raw.get("combos") or [] + total = (raw.get("pagination") or {}).get("total") + more = offset + len(rows) < int(total) if total is not None else len(rows) >= wanted + return Page([combo_of(row) for row in rows], next_cursor=str(offset + len(rows)) if more and rows else None) + + async def fetch_combo(self, market_id: str) -> dict[str, Any]: + """One combo by its instrument (`gemini:GEMI-CMB-...`), with its legs + and how each has resolved (public).""" + symbol = symbol_of(market_id) + try: + raw = await self._combos(f"{PREFIX}/combos/{symbol}") + except MarketNotFound as exc: + raise MarketNotFound(f"{VENUE}: no combo {symbol}", body=exc.body, status=exc.status) from None + return combo_of(raw or {}) + + async def create_combo(self, legs: list[Any]) -> dict[str, Any]: + """The canonical combo for these legs, created if Gemini has none: + the same set of legs in any order answers the same combo + (`info["alreadyExisted"]`). Legs name Gemini's numeric contract ids + (`combo_legs_of`). Signed; needs the key's `PredictionsNewOrder` + permission. It places nothing: trade the combo with `create_order` on + its `market_id`. Not enabled in production yet (`CombosUnavailable`).""" + raw = await self._combos(f"{PREFIX}/combos", fields={"legs": combo_legs_of(legs)}, write=True) or {} + combo = combo_of(raw.get("combo") or {}) + return {**combo, "info": raw} + + async def create_rfq(self, *args: Any, **kwargs: Any) -> Any: + """Not offered by Gemini's API: see `RFQ_APP_ONLY`.""" + raise NotSupported(RFQ_APP_ONLY) + + async def accept_quote(self, *args: Any, **kwargs: Any) -> Any: + """Not offered by Gemini's API: see `RFQ_APP_ONLY`. A requester's + winning quote still arrives on `GeminiRfqStream.watch_deliveries()`.""" + raise NotSupported(RFQ_APP_ONLY) + async def _contract(self, symbol: str) -> dict[str, Any]: """The public contract record for a symbol, found through its event.""" for ticker in candidate_event_tickers(symbol): diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index a9d7c38..968b449 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -71,7 +71,7 @@ def __getattr__(name: str): if name == "AzuroMarketStream": from .azuro import AzuroMarketStream return AzuroMarketStream - if name in ("GeminiMarketStream", "GeminiUserStream"): + if name in ("GeminiMarketStream", "GeminiUserStream", "GeminiRfqStream"): from . import gemini return getattr(gemini, name) if name in ("HyperliquidMarketStream", "HyperliquidUserStream"): @@ -93,5 +93,5 @@ def __getattr__(name: str): "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", - "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", + "BetfairMarketStream", "SmarketsMarketStream", "NovigMarketStream", "NovigUserStream", "MatchbookMarketStream", "ProphetXMarketStream", "ProphetXUserStream", "AzuroMarketStream", "MyriadMarketStream", "MyriadUserStream", "GeminiMarketStream", "GeminiUserStream", "GeminiRfqStream", ] diff --git a/synpath/ws/gemini.py b/synpath/ws/gemini.py index a10b0cd..5f1175c 100644 --- a/synpath/ws/gemini.py +++ b/synpath/ws/gemini.py @@ -26,24 +26,46 @@ fields. Fills ride on `FILLED` and `PARTIALLY_FILLED` frames (`Z` the execution's quantity, `L` its price, `n` its fee). Nothing is replayed after a reconnect, so one is reported with `reconcile_required`. + +**Combo RFQ, the maker side** (`GeminiRfqStream`). Gemini runs combo RFQ as +a sealed auction on this connection: a requester opens one in the Gemini app +(there is no API for that), `requestForQuote` broadcasts it to everyone, +makers answer with `rfq.submit_quote` inside a one-second window (`w`), and +at close only the best eligible quote is disclosed, privately, to the +requester, who has five seconds to accept. The winning maker then has one +second for its last look (`rfq.confirm_quote`). Quotes are single-sided (the +price the combo's YES is sold at), immutable, one per account per RFQ, and +withdrawing one does not free the slot. The private deliveries +(`requestForQuote@account`) are at least once and keyed by a delivery id, +which this stream deduplicates. Quote methods are never queued for a later +connection and never resent: an answer that does not come is an unknown +outcome to reconcile on the delivery stream. """ from __future__ import annotations +import asyncio +import time from collections import OrderedDict from decimal import Decimal from typing import Any from .. import ids from ..base import Capability +from ..errors import ( + AuthenticationError, BadRequest, ExchangeError, ExchangeNotAvailable, NetworkError, RateLimitExceeded, + RequestTimeout, +) from ..gemini import native_symbol from ..trading.credentials import GeminiCredentials -from ..trading.gemini import WS_STATUS, GeminiSigner, yes_leg, yes_price +from ..trading.errors import CredentialsMissing, InvalidOrder, OrderRejected, PermissionDenied +from ..trading.gemini import DEFAULT_PRECISION, WS_STATUS, GeminiSigner, yes_leg, yes_price +from ..trading.money import D, validate_price, validate_quantity from ..trading.types import ( Account, Balance, Fill, Liquidity, Order, OrderStatus, OrderType, Position, PositionSide, Side, TimeInForce, ) from .base import ( BalanceEvent, BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, - QuoteEvent, Stream, TradeEvent, maybe_D, + QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, ) VENUE = "gemini" @@ -538,3 +560,324 @@ def position_of_row(row: dict[str, Any], *, account: Account | None = None, time payout=payout, timestamp=timestamp, info={**row, "result": result if result in ("yes", "no") else None}, ) + + +# --------------------------------------------------------------------------- +# Combo RFQ (maker side) +# --------------------------------------------------------------------------- + +RFQ_STATES = frozenset({"OPEN", "PENDING_ACCEPTANCE", "CONFIRMING", "FINALIZING", "FINALIZED", "CANCELLED", + "EXPIRED", "FAILED"}) +RFQ_TERMINAL = frozenset({"FINALIZED", "CANCELLED", "EXPIRED", "FAILED"}) +"""An RFQ in one of these is over. Only `FINALIZED` traded.""" +DELIVERY_SCOPES = ("account", "session") +DELIVERY_MEMORY = 10_000 +"""Private delivery ids remembered for deduplication, most recent first.""" +RFQ_MEMORY = 10_000 +"""Public RFQs whose last state the stream keeps.""" +CLIENT_ID_MAX = 36 + +WS_ERRORS: dict[int, type[ExchangeError] | type[NetworkError]] = { + -1002: AuthenticationError, + -1004: PermissionDenied, + -1013: BadRequest, + -2010: OrderRejected, + -1003: RateLimitExceeded, + -1101: ExchangeNotAvailable, +} +"""Gemini's WebSocket error codes for the quote methods. -1000 (internal +error) and anything unlisted is an `ExchangeError`; its outcome is unknown.""" + + +def rfq_of(event: dict[str, Any]) -> dict[str, Any]: + """A public `requestForQuote` event: the auction and its state, never a + quote or a participant. `market_id` is the combo's own instrument where + Gemini names it; each leg is Gemini's contract id (`c`) with the outcome + the combo takes on it, and the leg's own instrument where named. Exactly + one of `notional` (USD) and `quantity` (contracts) sizes it; + `fill_quantity` appears once the requester accepts and counts only on + `FINALIZED`. Times are milliseconds: `quote_deadline` (`w`, the end of + the one-second quoting window), `expires_at` (`x`), `created_at` (`c`).""" + symbol = event.get("s") + return { + "rfq_id": str(event.get("r") or ""), + "market_id": ids.qualify(VENUE, native_symbol(str(symbol))) if symbol else None, + "legs": [ + { + "contract_id": str(leg.get("c") or ""), + "outcome": outcome_word(leg.get("o")), + "market_id": ids.qualify(VENUE, native_symbol(str(leg["s"]))) if leg.get("s") else None, + } + for leg in event.get("l") or [] if isinstance(leg, dict) + ], + "notional": maybe_D(event.get("n")), + "quantity": maybe_D(event.get("q")), + "fill_quantity": maybe_D(event.get("f")), + "state": str(event.get("S") or ""), + "quote_deadline": event.get("w"), + "expires_at": event.get("x"), + "created_at": event.get("c"), + "timestamp": event.get("E"), + "info": event, + } + + +def delivery_of(event: dict[str, Any]) -> dict[str, Any]: + """A private `requestForQuote@account` delivery. `transition` is what + happened: `CLOSED` discloses the winning quote to the requester, + `ACCEPTED` tells the winning maker to confirm within a second, then + `CONFIRMED` / `DECLINED` and `FINALIZED` / `FAILED`. The quote fields + (`quote_id`, `price`, `size`, `quote_status`, `valid_until`) are absent on + an RFQ-level delivery. `delivery_id` is Gemini's idempotency key.""" + return { + "delivery_id": str(event.get("i") or ""), + "rfq_id": str(event.get("r") or ""), + "transition": str(event.get("x") or ""), + "state": str(event.get("S") or ""), + "quote_id": event.get("q"), + "price": maybe_D(event.get("p")), + "size": maybe_D(event.get("sz")), + "quote_status": event.get("qs"), + "valid_until": event.get("vu"), + "timestamp": event.get("E"), + "info": event, + } + + +def quote_params(rfq_id: str, price: Any, quantity: Any, *, valid_until: int | None = None, + client_id: str | None = None, now_ms: int | None = None) -> dict[str, Any]: + """`rfq.submit_quote` params, checked before anything is sent: a price on + the cent tick strictly inside (0, 1), a positive quantity on the + hundredth-contract grid (an RFQ's grid is whole contracts or hundredths; + the venue refuses what is off its own), `valid_until` in the future, a + `client_id` of at most 36 printable ASCII characters.""" + if not str(rfq_id or "").strip(): + raise InvalidOrder(f"{VENUE}: rfq_id is required") + price_d = validate_price(D(price), DEFAULT_PRECISION) + quantity_d = validate_quantity(D(quantity), DEFAULT_PRECISION) + params: dict[str, Any] = {"rfqId": str(rfq_id), "price": format(price_d, "f"), "quantity": format(quantity_d, "f")} + if valid_until is not None: + if isinstance(valid_until, bool) or not isinstance(valid_until, int): + raise InvalidOrder(f"{VENUE}: valid_until is a Unix time in milliseconds, got {valid_until!r}") + if valid_until <= (now_ms if now_ms is not None else int(time.time() * 1000)): + raise InvalidOrder(f"{VENUE}: valid_until must be in the future") + params["validUntil"] = valid_until + if client_id is not None: + if not client_id or len(client_id) > CLIENT_ID_MAX or not all(" " <= ch <= "~" for ch in client_id): + raise InvalidOrder(f"{VENUE}: client_id is 1 to {CLIENT_ID_MAX} printable ASCII characters") + params["clientId"] = client_id + return params + + +def ws_error(message: dict[str, Any], *, method: str) -> Exception: + """A WebSocket error answer as the typed error, Gemini's code and text kept.""" + error = message.get("error") if isinstance(message.get("error"), dict) else {} + code, text = error.get("code"), str(error.get("msg") or "") + label = f"{VENUE}: {method} refused: {code} {text}".rstrip() + kind = WS_ERRORS.get(code) if isinstance(code, int) else None + if kind is RateLimitExceeded: + return RateLimitExceeded(label) + if kind is OrderRejected: + return OrderRejected(label, reason=str(code), info=message, body=message, status=message.get("status")) + if kind is not None: + return kind(label, body=message, status=message.get("status")) # type: ignore[call-arg] + return ExchangeError(f"{label} (outcome unknown: reconcile before acting again)", body=message, + status=message.get("status")) + + +class GeminiRfqStream(_GeminiStream): + """Gemini's combo RFQ auctions, and quoting them as a market maker. + + Discovery (`watch_rfqs`) is public. Deliveries (`watch_deliveries`) and + the quote methods need an account-scoped key with a time-based nonce, the + handshake signed; Gemini also requires an account eligible to quote. + + ```python + async with GeminiRfqStream(creds) as stream: + await stream.watch_rfqs() + await stream.watch_deliveries() + async for event in stream: + if getattr(event, "name", None) == "rfq" and event.payload["state"] == "OPEN": + answer = await stream.submit_quote(event.payload["rfq_id"], "0.55", "100") + elif getattr(event, "name", None) == "rfq_delivery" and event.payload["transition"] == "ACCEPTED": + await stream.confirm_quote(event.payload["rfq_id"], event.payload["quote_id"], True) + ``` + + Events are `VenueEvent`s: `name="rfq"` with `rfq_of(...)` as the payload, + `name="rfq_delivery"` with `delivery_of(...)`, each delivery once. + """ + + name = "rfq" + has: dict[str, Capability] = {"rfq": True} + + def __init__(self, credentials: GeminiCredentials | None = None, *, sandbox: bool = False, url: str | None = None, + signer: GeminiSigner | None = None, reply_timeout: float = 5.0, clock: Any = time.time, + **kwargs: Any): + sandboxed = sandbox or (credentials is not None and credentials.env == "sandbox") + super().__init__(url or (SANDBOX_WS_URL if sandboxed else WS_URL), **kwargs) + if signer is None and credentials is not None: + signer = GeminiSigner(credentials.api_key, credentials.api_secret) + self.signer = signer + self.private = signer is not None + self.reply_timeout = reply_timeout + self._clock = clock + self.rfqs: OrderedDict[str, dict[str, Any]] = OrderedDict() + """RFQ id -> its last public state (`rfq_of`).""" + self.quotes: dict[str, str] = {} + """RFQ id -> the id of the quote this account made on it, this session.""" + self._seen: OrderedDict[str, None] = OrderedDict() + self._pending: dict[int, tuple[str, asyncio.Future]] = {} + + def headers(self) -> dict[str, str]: + return self.signer.ws_headers() if self.signer is not None else {} + + def _need_key(self, what: str) -> None: + if self.signer is None: + raise CredentialsMissing(f"{VENUE}: {what} needs an account-scoped API key (GEMINI_API_KEY, " + f"GEMINI_API_SECRET) on the connection's handshake") + + async def watch_rfqs(self) -> None: + """Every RFQ auction as it opens and ends (public, anonymous). Not + replayed after a reconnect: auctions last seconds.""" + await self._subscribe(["requestForQuote"]) + + async def watch_deliveries(self, scope: str = "account") -> None: + """This account's private RFQ deliveries: as a maker, the acceptance + of its quote and the outcome; as a requester (an RFQ opened in the + Gemini app), the winning quote and the outcome. `scope` is `account` + or `session`, the same deliveries; a connection takes one of them.""" + self._need_key("watch_deliveries") + if scope not in DELIVERY_SCOPES: + raise BadRequest(f"{VENUE}: scope is account or session, got {scope!r}") + other = f"requestForQuote@{'session' if scope == 'account' else 'account'}" + if other in self.streams: + raise BadRequest(f"{VENUE}: {other} is already watched; a connection takes one delivery scope") + await self._subscribe([f"requestForQuote@{scope}"]) + + # -- quote methods ---------------------------------------------------------- + + async def submit_quote(self, rfq_id: str, price: Any, quantity: Any, *, valid_until: int | None = None, + client_id: str | None = None) -> dict[str, Any]: + """Quote an open RFQ: sell its combo's YES at `price` a contract, for + up to `quantity` contracts (which must cover the whole request to be + eligible: `quantity * price >= notional` on a notional-sized RFQ). + Strictly before the RFQ's `quote_deadline`; immutable; one per account + per RFQ. `valid_until` (ms) defaults to the deadline. `client_id` + comes back as the maker fill's client order id. Returns Gemini's + `{"rfqId", "quoteId"}`.""" + self._need_key("submit_quote") + now = int(self._clock() * 1000) + params = quote_params(rfq_id, price, quantity, valid_until=valid_until, client_id=client_id, now_ms=now) + known = self.rfqs.get(str(rfq_id)) + if known is not None: + deadline = known.get("quote_deadline") + if known.get("state") != "OPEN" or (deadline is not None and int(deadline) <= now): + raise InvalidOrder(f"{VENUE}: RFQ {rfq_id} is no longer quotable ({known.get('state')}, " + f"window closed at {deadline})") + result = await self._request("rfq.submit_quote", params) + if result.get("quoteId"): + self.quotes[str(rfq_id)] = str(result["quoteId"]) + return result + + async def withdraw_quote(self, rfq_id: str, quote_id: str) -> dict[str, Any]: + """Pull this account's quote before the window closes. Final: the + quote cannot come back and the RFQ takes no other from this account. + Needs the session's `cancel_orders` capability.""" + self._need_key("withdraw_quote") + if not str(rfq_id or "").strip() or not str(quote_id or "").strip(): + raise InvalidOrder(f"{VENUE}: rfq_id and quote_id are required") + return await self._request("rfq.withdraw_quote", {"rfqId": str(rfq_id), "quoteId": str(quote_id)}) + + async def confirm_quote(self, rfq_id: str, quote_id: str, confirm: bool) -> dict[str, Any]: + """The winning maker's last look, within one second of the RFQ + entering `CONFIRMING` (the `ACCEPTED` delivery): `True` trades, + `False` declines and fails the RFQ. There is no default: Gemini + refuses an omitted decision. Confirming commits capital, and the + maker's order rests on the book first, so it can fill in part even if + the RFQ then fails; watch `GeminiUserStream` for the maker fills.""" + self._need_key("confirm_quote") + if not isinstance(confirm, bool): + raise InvalidOrder(f"{VENUE}: confirm is True or False, got {confirm!r}") + if not str(rfq_id or "").strip() or not str(quote_id or "").strip(): + raise InvalidOrder(f"{VENUE}: rfq_id and quote_id are required") + return await self._request("rfq.confirm_quote", + {"rfqId": str(rfq_id), "quoteId": str(quote_id), "confirm": confirm}) + + async def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + """Send one method on the live connection and wait for its answer. + Never queued for a later connection and never resent.""" + if self._ws is None: + raise NetworkError(f"{VENUE}: {method} not sent: the RFQ stream is not connected " + f"(a quote is never queued for a later connection)") + number = self._allocate() + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending[number] = (method, future) + try: + await self.send({"id": number, "method": method, "params": params}) + return await asyncio.wait_for(asyncio.shield(future), timeout=self.reply_timeout) + except asyncio.TimeoutError: + raise RequestTimeout(f"{VENUE}: {method} sent, no answer in {self.reply_timeout}s: outcome unknown; " + f"reconcile on the delivery stream, do not resend") from None + finally: + self._pending.pop(number, None) + + def on_disconnect(self) -> None: + for method, future in list(self._pending.values()): + if not future.done(): + future.set_exception(NetworkError( + f"{VENUE}: {method} sent, connection lost before the answer: outcome unknown; reconcile " + f"on the delivery stream, do not resend")) + self._pending.clear() + + # -- reading ---------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + number = message.get("id") + if "e" not in message and isinstance(number, int) and number in self._pending: + method, future = self._pending[number] + if not future.done(): + if message.get("status") == 200 and isinstance(message.get("result"), dict): + future.set_result(message["result"]) + elif message.get("status") == 200: + future.set_exception(ExchangeError(f"{VENUE}: {method} answered without a result", body=message, + status=200)) + else: + future.set_exception(ws_error(message, method=method)) + return [] + reply = self._reply(message) + if reply is not None: + return reply + if message.get("e") != "requestForQuote": + return [] + if "i" in message and isinstance(message.get("x"), str): + return self._on_delivery(message) + return self._on_rfq(message) + + def _on_rfq(self, message: dict[str, Any]) -> list[Event]: + rfq = rfq_of(message) + if not rfq["rfq_id"] or rfq["state"] not in RFQ_STATES: + self.status("error", f"unreadable RFQ event: {message!r}") + return [] + known = self.rfqs.get(rfq["rfq_id"]) + if known is not None: + # A later event may leave out what the opening one said (`w`, `x`, `s`). + rfq = {**known, **{k: v for k, v in rfq.items() if v not in (None, [])}} + self.rfqs[rfq["rfq_id"]] = rfq + self.rfqs.move_to_end(rfq["rfq_id"]) + while len(self.rfqs) > RFQ_MEMORY: + self.rfqs.popitem(last=False) + if rfq["state"] in RFQ_TERMINAL: + self.quotes.pop(rfq["rfq_id"], None) + return [VenueEvent(venue=VENUE, name="rfq", payload=rfq, timestamp=rfq["timestamp"])] + + def _on_delivery(self, message: dict[str, Any]) -> list[Event]: + delivery = delivery_of(message) + key = delivery["delivery_id"] + if key in self._seen: + return [] # at least once: the same transition again + self._seen[key] = None + while len(self._seen) > DELIVERY_MEMORY: + self._seen.popitem(last=False) + return [VenueEvent(venue=VENUE, name="rfq_delivery", payload=delivery, timestamp=delivery["timestamp"])] diff --git a/tests/samples/gemini_combo_rfq.json b/tests/samples/gemini_combo_rfq.json new file mode 100644 index 0000000..ecdd722 --- /dev/null +++ b/tests/samples/gemini_combo_rfq.json @@ -0,0 +1,115 @@ +{ + "note": "documented examples, specs/openapi/prediction-markets.yaml (Combos) and developer.gemini.com/prediction-markets/combos-rfq (websocket-streams, quote-methods, examples), 2026-10-25; nothing recorded: the combo REST routes are not enabled in production and quoting needs an eligible account", + "rest": { + "combos": { + "combos": [ + { + "contract": { + "contractId": "456", "contractName": "BTC EOY26 > $120k AND ETH EOY26 > $4k", + "contractTicker": "GEMI-CMB-0526-A7F3B2C1D4E5", "eventTicker": "GEMI-CMB-0526-A7F3B2C1D4E5", + "eventName": "BTC EOY26 > $120k AND ETH EOY26 > $4k", "category": "Combo", "contractStatus": "Active", + "eventType": "binary", "expiryDate": "2026-12-31T23:59:59Z", "resolvedAt": null, "resolutionSide": null, + "parentEventTicker": null, "startTime": null + }, + "legs": [ + { + "comboId": 456, "legIndex": 0, "contractId": "101", "requiredOutcome": "Yes", "legOutcome": null, + "resolvedAt": null, + "contract": { + "contractId": "101", "contractName": "BTC above $120,000 at year-end 2026", + "contractTicker": "GEMI-BTC-EOY26-HI120000", "eventTicker": "GEMI-BTC-EOY26", + "eventName": "Bitcoin Year-End 2026", "category": "Crypto", "contractStatus": "Active", + "eventType": "binary", "expiryDate": "2026-12-31T23:59:59Z", "resolvedAt": null, + "resolutionSide": null, "parentEventTicker": null, "startTime": null + } + }, + { + "comboId": 456, "legIndex": 1, "contractId": "202", "requiredOutcome": "Yes", "legOutcome": null, + "resolvedAt": null, + "contract": { + "contractId": "202", "contractName": "ETH above $4,000 at year-end 2026", + "contractTicker": "GEMI-ETH-EOY26-HI4000", "eventTicker": "GEMI-ETH-EOY26", + "eventName": "Ethereum Year-End 2026", "category": "Crypto", "contractStatus": "Active", + "eventType": "binary", "expiryDate": "2026-12-31T23:59:59Z", "resolvedAt": null, + "resolutionSide": null, "parentEventTicker": null, "startTime": null + } + } + ] + } + ], + "pagination": {"limit": 50, "offset": 0, "total": 1} + }, + "create_combo": { + "combo": { + "id": 456, "canonicalLegKey": "101:Yes|202:No", "legCount": 2, + "displayName": "BTC EOY26 > $120k AND ETH EOY26 <= $4k", "status": "Active", "instrumentId": 98765, + "instrumentSymbol": "GEMI-CMB-0526-A7F3B2C1D4E5", "instrumentRegistered": true, + "latestExpiryDate": "2026-12-31T23:59:59.000Z", "createdAt": "2026-05-01T12:00:00.000Z", + "updatedAt": "2026-05-01T12:00:00.000Z", + "legs": [ + {"comboId": 456, "legIndex": 0, "contractId": "101", "requiredOutcome": "Yes"}, + {"comboId": 456, "legIndex": 1, "contractId": "202", "requiredOutcome": "No"} + ] + }, + "alreadyExisted": false + }, + "errors": { + "combos_unavailable": {"error": "NOT_FOUND", "message": "Not Found"}, + "leg_not_found": {"error": "NOT_FOUND", "code": "COMBO_LEG_NOT_FOUND", + "message": "One or more contracts in this combo could not be found. Update your selection and try again."}, + "combo_not_found": {"error": "NOT_FOUND", "message": "Combo not found"}, + "validation": {"error": "InvalidInput", "code": "COMBO_VALIDATION_ERROR", "message": "a combo needs 2-6 legs"} + } + }, + "ws": { + "rfq_open": { + "e": "requestForQuote", "E": 1780000000000, "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", + "s": "GEMI-CMB-260306-A1B2C3D4E5F6G7H8-CMB", + "l": [{"c": "123456", "o": "YES", "s": "GEMI-XRPUSD-260828"}, {"c": "123789", "o": "NO"}], + "n": "50000.00", "S": "OPEN", "w": 1780000001000, "x": 1780000120000, "c": 1780000000000 + }, + "rfq_finalized": { + "e": "requestForQuote", "E": 1780000004500, "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", + "l": [{"c": "123456", "o": "YES", "s": "GEMI-XRPUSD-260828"}, {"c": "123789", "o": "NO"}], + "n": "50000.00", "f": "90909", "S": "FINALIZED", "c": 1780000000000 + }, + "delivery_closed": { + "e": "requestForQuote", "i": "b16097e9-3d7f-5910-9820-eeedf51a8be3", "E": 1780000001100, + "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "x": "CLOSED", "S": "PENDING_ACCEPTANCE", + "q": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "p": "0.55", "sz": "100000", "qs": "ACTIVE", "vu": 1780000001000 + }, + "delivery_accepted": { + "e": "requestForQuote", "i": "88bfe6b3-be05-56c6-bd1b-51b25fafb27a", "E": 1780000004100, + "r": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "x": "ACCEPTED", "S": "CONFIRMING", + "q": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "p": "0.55", "sz": "100000", "qs": "ACTIVE", "vu": 1780000001000 + }, + "submit_quote": { + "id": 1, "method": "rfq.submit_quote", + "params": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "price": "0.55", "quantity": "1000", + "validUntil": 1780000060000, "clientId": "maker-fill-123"} + }, + "submit_quote_response": { + "id": 1, "status": 200, + "result": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6"} + }, + "withdraw_quote": { + "id": 2, "method": "rfq.withdraw_quote", + "params": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6"} + }, + "withdraw_quote_response": { + "id": 2, "status": 200, + "result": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6"} + }, + "confirm_quote": { + "id": 3, "method": "rfq.confirm_quote", + "params": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "confirm": true} + }, + "confirm_quote_response": { + "id": 3, "status": 200, + "result": {"rfqId": "01J9Z3K7Q2N8M4P6R8T0V2W4X6", "quoteId": "01J9Z2K7Q2N8M4P6R8T0V2W4X6", "confirmed": true} + }, + "closed_auction_error": { + "id": 3, "status": 400, "error": {"code": -2010, "msg": "RFQ is not in a valid state for this operation"} + } + } +} diff --git a/tests/test_gemini_combo_rfq.py b/tests/test_gemini_combo_rfq.py new file mode 100644 index 0000000..25138ea --- /dev/null +++ b/tests/test_gemini_combo_rfq.py @@ -0,0 +1,400 @@ +"""Gemini combos and combo RFQ: the combo routes against a scripted venue, +the maker side of the sealed auction against a fake socket. Every payload is +one of Gemini's documented examples (`tests/samples/gemini_combo_rfq.json`): +the combo routes are not enabled in production and quoting needs an +eligible account, so nothing was recorded. Nothing here reaches Gemini.""" +from __future__ import annotations + +import asyncio +import base64 +import json +from decimal import Decimal + +import httpx +import pytest + +import synpath +from synpath.errors import AuthenticationError, BadRequest, ExchangeNotAvailable, MarketNotFound, NetworkError, \ + NotSupported, RateLimitExceeded, RequestTimeout +from synpath.trading.credentials import GeminiCredentials +from synpath.trading.errors import CredentialsMissing, InvalidOrder, OrderRejected, PermissionDenied +from synpath.trading.gemini import ( + CombosUnavailable, GeminiSigner, GeminiTrading, RFQ_APP_ONLY, combo_legs_of, combo_of, +) +from synpath.ws.base import StreamStatusEvent, VenueEvent +from synpath.ws.gemini import GeminiRfqStream, delivery_of, quote_params, rfq_of, ws_error + +from conftest import load + +SAMPLE = load("gemini_combo_rfq.json") +REST, WS = SAMPLE["rest"], SAMPLE["ws"] +SECRET = "synpath-test-secret" +KEY = "account-test0000000000000000" +CREDS = GeminiCredentials(api_key=KEY, api_secret=SECRET) +SANDBOX = GeminiCredentials(api_key=KEY, api_secret=SECRET, env="sandbox") +COMBO = "gemini:GEMI-CMB-0526-A7F3B2C1D4E5" +RFQ = "01J9Z3K7Q2N8M4P6R8T0V2W4X6" +QUOTE = "01J9Z2K7Q2N8M4P6R8T0V2W4X6" + +# Computed independently with `openssl dgst -sha384 -hmac synpath-test-secret` +# over the base64 of the payload below. +COMBO_PAYLOAD = ( + "eyJyZXF1ZXN0IjoiL3YxL3ByZWRpY3Rpb24tbWFya2V0cy9jb21ib3MiLCJub25jZSI6IjE3NzYyOTQ0NDciLCJsZWdzIjpbeyJjb250cmFj" + "dElkIjoiMTAxIiwicmVxdWlyZWRPdXRjb21lIjoiWWVzIn0seyJjb250cmFjdElkIjoiMjAyIiwicmVxdWlyZWRPdXRjb21lIjoiTm8ifV19" +) +COMBO_SIGNATURE = "677b0b807a6705f677d2a55a07d91c21f8d51361c550d0fa59cfacf71d8fdcaea8963a7ce74143923d743b98c8d965a9" + + +def run(coro): + return asyncio.run(coro) + + +def decoded(req: httpx.Request) -> dict: + return json.loads(base64.b64decode(req.headers["X-GEMINI-PAYLOAD"])) + + +# --------------------------------------------------------------------------- +# REST: combos +# --------------------------------------------------------------------------- + +class Venue: + """The combo routes as documented; `disabled` answers as production does.""" + + def __init__(self, *, disabled: bool = False): + self.disabled = disabled + self.requests: list[httpx.Request] = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + self.requests.append(req) + path = req.url.path + if self.disabled and path.startswith("/v1/prediction-markets/combos"): + return httpx.Response(404, json=REST["errors"]["combos_unavailable"]) + if path == "/v1/prediction-markets/combos" and req.method == "GET": + return httpx.Response(200, json=REST["combos"]) + if path == "/v1/prediction-markets/combos" and req.method == "POST": + assert req.content == b"", "a private request sends no body" + legs = decoded(req)["legs"] + if any(leg["contractId"] == "999" for leg in legs): + return httpx.Response(404, json=REST["errors"]["leg_not_found"]) + return httpx.Response(201, json=REST["create_combo"]) + if path == "/v1/prediction-markets/combos/GEMI-CMB-0526-A7F3B2C1D4E5": + return httpx.Response(200, json=REST["combos"]["combos"][0]) + if path.startswith("/v1/prediction-markets/combos/"): + return httpx.Response(404, json=REST["errors"]["combo_not_found"]) + return httpx.Response(404, json={"error": "no route"}) + + +def trading(venue: Venue, creds: GeminiCredentials = SANDBOX, **kwargs) -> GeminiTrading: + from synpath.trading.limiter import BudgetLimiter + + return GeminiTrading(creds, client=httpx.AsyncClient(transport=httpx.MockTransport(venue)), + limiter=BudgetLimiter(read_per_second=1000, write_per_second=1000), **kwargs) + + +class TestCombos: + def test_a_listing_row_reads_as_a_combo_with_its_legs(self): + combo = combo_of(REST["combos"]["combos"][0]) + assert combo["market_id"] == COMBO and combo["combo_id"] == "456" and combo["status"] == "active" + assert [(l["contract_id"], l["outcome"], l["market_id"]) for l in combo["legs"]] == [ + ("101", "yes", "gemini:GEMI-BTC-EOY26-HI120000"), ("202", "yes", "gemini:GEMI-ETH-EOY26-HI4000")] + assert combo["legs"][0]["result"] is None and combo["expires_at"] == 1798761599000 + + def test_a_create_answer_reads_the_same_way(self): + combo = combo_of(REST["create_combo"]["combo"]) + assert combo["market_id"] == COMBO and combo["registered"] is True and combo["combo_id"] == "456" + assert [(l["contract_id"], l["outcome"]) for l in combo["legs"]] == [("101", "yes"), ("202", "no")] + unregistered = combo_of({**REST["create_combo"]["combo"], "instrumentRegistered": False, + "instrumentSymbol": None}) + assert unregistered["market_id"] is None + + def test_legs_are_checked_before_signing(self): + assert combo_legs_of([("101", "yes"), {"contract_id": "202", "outcome": "NO"}]) == [ + {"contractId": "101", "requiredOutcome": "Yes"}, {"contractId": "202", "requiredOutcome": "No"}] + with pytest.raises(InvalidOrder, match="2 to 6"): + combo_legs_of([("101", "yes")]) + with pytest.raises(InvalidOrder, match="2 to 6"): + combo_legs_of([(str(n), "yes") for n in range(1, 8)]) + with pytest.raises(InvalidOrder, match="once"): + combo_legs_of([("101", "yes"), ("101", "no")]) + with pytest.raises(InvalidOrder, match="numeric contract id"): + combo_legs_of([("GEMI-BTC-EOY26-HI120000", "yes"), ("202", "no")]) + with pytest.raises(InvalidOrder, match="yes or no"): + combo_legs_of([("101", "maybe"), ("202", "no")]) + + def test_create_combo_signs_its_legs_as_a_known_answer(self): + headers = GeminiSigner(KEY, SECRET).headers( + "/v1/prediction-markets/combos", {"legs": combo_legs_of([("101", "yes"), ("202", "no")])}, + nonce="1776294447") + assert headers["X-GEMINI-PAYLOAD"] == COMBO_PAYLOAD and headers["X-GEMINI-SIGNATURE"] == COMBO_SIGNATURE + + def test_create_combo_against_the_sandbox(self): + venue = Venue() + adapter = trading(venue, clock=lambda: 1776294447) + assert adapter.base_url == "https://api.sandbox.gemini.com" + combo = run(adapter.create_combo([("101", "yes"), ("202", "no")])) + [sent] = venue.requests + assert sent.method == "POST" and sent.headers["X-GEMINI-PAYLOAD"] == COMBO_PAYLOAD + assert sent.headers["X-GEMINI-SIGNATURE"] == COMBO_SIGNATURE + assert combo["market_id"] == COMBO and combo["info"]["alreadyExisted"] is False + + def test_listing_and_lookup_are_public_and_page_by_offset(self): + venue = Venue() + adapter = trading(venue) + page = run(adapter.fetch_combos(contract_id="101", registered=True, limit=10)) + assert [c["market_id"] for c in page] == [COMBO] and page.next_cursor is None + listing = venue.requests[0] + assert "X-GEMINI-SIGNATURE" not in listing.headers + assert dict(listing.url.params) == {"contractId": "101", "instrumentRegistered": "true", "limit": "10"} + assert run(adapter.fetch_combo(COMBO))["legs"][1]["contract_id"] == "202" + with pytest.raises(MarketNotFound, match="no combo"): + run(adapter.fetch_combo("gemini:GEMI-CMB-0526-000000000000")) + + def test_production_says_combos_are_unavailable_with_geminis_answer(self): + adapter = trading(Venue(disabled=True), creds=CREDS) + for call in (adapter.fetch_combos(), adapter.fetch_combo(COMBO), + adapter.create_combo([("101", "yes"), ("202", "no")])): + with pytest.raises(CombosUnavailable) as caught: + run(call) + assert "production" in str(caught.value) and "NOT_FOUND Not Found" in str(caught.value) + assert caught.value.status == 404 and caught.value.body == REST["errors"]["combos_unavailable"] + + def test_a_missing_leg_is_not_mistaken_for_combos_being_off(self): + with pytest.raises(MarketNotFound) as caught: + run(trading(Venue()).create_combo([("101", "yes"), ("999", "no")])) + assert not isinstance(caught.value, CombosUnavailable) + assert caught.value.body["code"] == "COMBO_LEG_NOT_FOUND" + + def test_the_requester_side_is_not_an_api(self): + adapter = trading(Venue()) + for call in (adapter.create_rfq(COMBO, quantity=Decimal("1")), adapter.accept_quote(RFQ, QUOTE)): + with pytest.raises(NotSupported, match="Gemini app and website"): + run(call) + assert "GeminiRfqStream" in RFQ_APP_ONLY + assert GeminiTrading.has["rfq"] == "partial" + + def test_a_combo_is_traded_as_its_own_instrument(self): + from synpath.trading.gemini import translate_order + from synpath.trading.types import OrderRequest, Side + + body = translate_order(OrderRequest(market_id=COMBO, side=Side.BUY, quantity=Decimal("10"), + price=Decimal("0.42"))) + assert body["symbol"] == "GEMI-CMB-0526-A7F3B2C1D4E5" and body["outcome"] == "yes" + + +# --------------------------------------------------------------------------- +# WebSocket: the maker side of the auction +# --------------------------------------------------------------------------- + +class FakeSocket: + """Records frames; answers a method with `answers[method]` when given.""" + + def __init__(self, stream: GeminiRfqStream | None = None, answers: dict | None = None): + self.sent: list = [] + self.stream = stream + self.answers = answers or {} + + async def send(self, text): + frame = json.loads(text) + self.sent.append(frame) + answer = self.answers.get(frame.get("method")) + if answer is not None and self.stream is not None: + reply = {**answer, "id": frame["id"]} + asyncio.get_running_loop().call_soon(self.stream._dispatch, json.dumps(reply)) + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +def connected(stream: GeminiRfqStream, **answers) -> FakeSocket: + socket = FakeSocket(stream, {m.replace("_", ".", 1): a for m, a in answers.items()}) + stream._ws = socket + return socket + + +class TestRfqEvents: + def test_the_public_broadcast_reads_as_an_auction(self): + rfq = rfq_of(WS["rfq_open"]) + assert rfq["rfq_id"] == RFQ and rfq["state"] == "OPEN" + assert rfq["market_id"] == "gemini:GEMI-CMB-260306-A1B2C3D4E5F6G7H8-CMB" + assert rfq["legs"] == [ + {"contract_id": "123456", "outcome": "yes", "market_id": "gemini:GEMI-XRPUSD-260828"}, + {"contract_id": "123789", "outcome": "no", "market_id": None}] + assert rfq["notional"] == Decimal("50000.00") and rfq["quantity"] is None + assert (rfq["quote_deadline"], rfq["expires_at"], rfq["created_at"]) == (1780000001000, 1780000120000, + 1780000000000) + + def test_a_private_delivery_reads_as_the_winning_quote(self): + delivery = delivery_of(WS["delivery_closed"]) + assert (delivery["transition"], delivery["state"], delivery["quote_id"]) == ("CLOSED", "PENDING_ACCEPTANCE", + QUOTE) + assert delivery["price"] == Decimal("0.55") and delivery["size"] == Decimal("100000") + assert delivery["valid_until"] == 1780000001000 and delivery["quote_status"] == "ACTIVE" + + def test_the_stream_keeps_each_auctions_last_state(self): + stream = GeminiRfqStream() + [opened] = stream.handle(WS["rfq_open"]) + [finished] = stream.handle(WS["rfq_finalized"]) + assert isinstance(opened, VenueEvent) and opened.name == "rfq" + # The terminal event leaves out `w`, `x` and `s`; what the opening one said is kept. + assert finished.payload["state"] == "FINALIZED" and finished.payload["fill_quantity"] == Decimal("90909") + assert finished.payload["quote_deadline"] == 1780000001000 + assert finished.payload["market_id"] == "gemini:GEMI-CMB-260306-A1B2C3D4E5F6G7H8-CMB" + + def test_deliveries_are_at_least_once_and_are_deduplicated(self): + stream = GeminiRfqStream(CREDS) + assert [e.name for e in stream.handle(WS["delivery_closed"])] == ["rfq_delivery"] + assert stream.handle(WS["delivery_closed"]) == [] + [accepted] = stream.handle(WS["delivery_accepted"]) + assert accepted.payload["transition"] == "ACCEPTED" and accepted.payload["state"] == "CONFIRMING" + + def test_an_unreadable_rfq_is_a_status_not_a_crash(self): + stream = GeminiRfqStream() + assert stream.handle({**WS["rfq_open"], "S": "SOMETHING"}) == [] + [status] = drain(stream) + assert isinstance(status, StreamStatusEvent) and status.state == "error" + + +class TestRfqSubscriptions: + def test_it_is_exported_and_discovery_is_public(self): + assert synpath.GeminiRfqStream is GeminiRfqStream + stream = GeminiRfqStream() + assert stream.url == "wss://ws.gemini.com" and stream.headers() == {} and stream.private is False + assert GeminiRfqStream(sandbox=True).url == "wss://ws.sandbox.gemini.com" + assert GeminiRfqStream(SANDBOX).url == "wss://ws.sandbox.gemini.com" + + async def go(): + socket = connected(stream) + await stream.watch_rfqs() + return socket.sent + + assert run(go()) == [{"id": 1, "method": "SUBSCRIBE", "params": ["requestForQuote"]}] + + def test_deliveries_need_a_key_on_the_signed_handshake(self): + with pytest.raises(CredentialsMissing): + run(GeminiRfqStream().watch_deliveries()) + stream = GeminiRfqStream(CREDS, signer=GeminiSigner(KEY, SECRET, clock=lambda: 1776294447)) + assert stream.private is True + headers = stream.headers() + assert headers["X-GEMINI-NONCE"] == "1776294447" and headers["X-GEMINI-PAYLOAD"] == "MTc3NjI5NDQ0Nw==" + assert headers["X-GEMINI-SIGNATURE"] == ( + "37e6fd382f7ec4cf24501d5ebf569a27e73d63fe0cc2a613c82611b9fd83b10d0ec51f4abcc686bb160c9cf9f72104fd") + + def test_a_connection_takes_one_delivery_scope(self): + async def go(): + stream = GeminiRfqStream(CREDS) + socket = connected(stream) + await stream.watch_deliveries() + with pytest.raises(BadRequest, match="one delivery scope"): + await stream.watch_deliveries("session") + with pytest.raises(BadRequest, match="account or session"): + await stream.watch_deliveries("all") + return socket.sent + + assert run(go()) == [{"id": 1, "method": "SUBSCRIBE", "params": ["requestForQuote@account"]}] + + +class TestQuoteMethods: + def test_submit_sends_the_documented_frame_and_returns_the_quote_id(self): + async def go(): + stream = GeminiRfqStream(CREDS, clock=lambda: 1780000000.5) + stream.handle(WS["rfq_open"]) # open, its window closing at 1780000001000 + socket = connected(stream, rfq_submit_quote=WS["submit_quote_response"]) + result = await stream.submit_quote(RFQ, "0.55", "1000", valid_until=1780000060000, + client_id="maker-fill-123") + return stream, socket.sent, result + + stream, sent, result = run(go()) + [frame] = sent + assert {k: v for k, v in frame.items() if k != "id"} == {k: v for k, v in WS["submit_quote"].items() if k != "id"} + assert result == WS["submit_quote_response"]["result"] and stream.quotes[RFQ] == QUOTE + + def test_withdraw_and_confirm_send_the_documented_frames(self): + async def go(): + stream = GeminiRfqStream(CREDS) + socket = connected(stream, rfq_withdraw_quote=WS["withdraw_quote_response"], + rfq_confirm_quote=WS["confirm_quote_response"]) + withdrawn = await stream.withdraw_quote(RFQ, QUOTE) + confirmed = await stream.confirm_quote(RFQ, QUOTE, True) + return socket.sent, withdrawn, confirmed + + sent, withdrawn, confirmed = run(go()) + assert [{k: v for k, v in f.items() if k != "id"} for f in sent] == [ + {k: v for k, v in WS[name].items() if k != "id"} for name in ("withdraw_quote", "confirm_quote")] + assert withdrawn["quoteId"] == QUOTE and confirmed["confirmed"] is True + + def test_the_last_look_needs_an_explicit_decision(self): + stream = GeminiRfqStream(CREDS) + connected(stream) + with pytest.raises(InvalidOrder, match="True or False"): + run(stream.confirm_quote(RFQ, QUOTE, None)) # type: ignore[arg-type] + + def test_a_quote_is_checked_before_it_is_sent(self): + assert quote_params(RFQ, Decimal("0.55"), 100) == {"rfqId": RFQ, "price": "0.55", "quantity": "100"} + for bad in ({"price": "0.1234"}, {"price": "1"}, {"price": "0"}, {"quantity": "0"}, + {"quantity": "1.005"}, {"client_id": "x" * 37}, {"client_id": "café"}, + {"valid_until": 1}, {"valid_until": "1780000060000"}): + args = {"price": "0.55", "quantity": "100", **bad} + with pytest.raises(InvalidOrder): + quote_params(RFQ, args.pop("price"), args.pop("quantity"), now_ms=1780000000000, **args) + + def test_a_closed_window_is_refused_locally(self): + async def go(): + stream = GeminiRfqStream(CREDS, clock=lambda: 1780000001.0) + socket = connected(stream) + stream.handle(WS["rfq_open"]) # its window closes at 1780000001000 + with pytest.raises(InvalidOrder, match="no longer quotable"): + await stream.submit_quote(RFQ, "0.55", "100000") + return socket.sent + + assert run(go()) == [] + + def test_geminis_error_comes_back_typed_with_its_code(self): + async def go(): + stream = GeminiRfqStream(CREDS) + connected(stream, rfq_confirm_quote=WS["closed_auction_error"]) + await stream.confirm_quote(RFQ, QUOTE, True) + + with pytest.raises(OrderRejected) as caught: + run(go()) + assert caught.value.reason == "-2010" and "not in a valid state" in str(caught.value) + + @pytest.mark.parametrize("code, kind", [ + (-1002, AuthenticationError), (-1004, PermissionDenied), (-1013, BadRequest), (-1003, RateLimitExceeded), + (-1101, ExchangeNotAvailable), + ]) + def test_the_documented_error_codes(self, code, kind): + error = ws_error({"id": 1, "status": 400, "error": {"code": code, "msg": "no"}}, method="rfq.submit_quote") + assert isinstance(error, kind) + + def test_quote_methods_need_a_key_and_a_live_connection(self): + with pytest.raises(CredentialsMissing): + run(GeminiRfqStream().submit_quote(RFQ, "0.55", "100")) + with pytest.raises(NetworkError, match="never queued"): + run(GeminiRfqStream(CREDS).submit_quote(RFQ, "0.55", "100")) + + def test_no_answer_is_an_unknown_outcome_and_is_not_resent(self): + async def go(): + stream = GeminiRfqStream(CREDS, reply_timeout=0.05) + socket = connected(stream) + with pytest.raises(RequestTimeout, match="outcome unknown"): + await stream.submit_quote(RFQ, "0.55", "100") + return stream, socket.sent + + stream, sent = run(go()) + assert len(sent) == 1 and stream._pending == {} + + def test_a_dropped_connection_fails_the_waiting_method(self): + async def go(): + stream = GeminiRfqStream(CREDS) + connected(stream) + task = asyncio.ensure_future(stream.confirm_quote(RFQ, QUOTE, True)) + await asyncio.sleep(0) + stream.on_disconnect() + with pytest.raises(NetworkError, match="connection lost"): + await task + + run(go()) diff --git a/tests/test_gemini_trading.py b/tests/test_gemini_trading.py index 8ec129c..a7e1338 100644 --- a/tests/test_gemini_trading.py +++ b/tests/test_gemini_trading.py @@ -382,7 +382,7 @@ def test_a_rate_limited_write_is_not_resent(self): def test_the_capabilities_say_what_the_venue_holds(self): assert GeminiTrading.has["create_order"] is True and GeminiTrading.has["edit_order"] is False - assert GeminiTrading.has["fetch_my_trades"] == "partial" and GeminiTrading.has["rfq"] is False + assert GeminiTrading.has["fetch_my_trades"] == "partial" and GeminiTrading.has["rfq"] == "partial" class TestDoctor: From 3cde970b94d4b2e9f96c59afd9c47ce9d87fe4da Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 5/8] feat: novig-parlay-rfq Co-Authored-By: Claude Opus 5.5 --- docs/capabilities.md | 14 +- docs/streaming.md | 2 + docs/trading.md | 111 ++- synpath/trading/credentials.py | 73 ++ synpath/trading/novig.py | 2 +- synpath/trading/novig_rfq.py | 1219 ++++++++++++++++++++++++++ synpath/trading/novig_rfq_signing.py | 187 ++++ synpath/ws/novig_rfq.py | 251 ++++++ tests/samples/blake3_vectors.json | 67 ++ tests/samples/novig_rfq_docs.json | 248 ++++++ tests/test_novig_rfq.py | 887 +++++++++++++++++++ 11 files changed, 3055 insertions(+), 6 deletions(-) create mode 100644 synpath/trading/novig_rfq.py create mode 100644 synpath/trading/novig_rfq_signing.py create mode 100644 synpath/ws/novig_rfq.py create mode 100644 tests/samples/blake3_vectors.json create mode 100644 tests/samples/novig_rfq_docs.json create mode 100644 tests/test_novig_rfq.py diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..ca4105e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -216,8 +216,16 @@ Novig's identity check, from a US location outside Arizona, Michigan and Nevada, with a recent device geolocation from the Novig app; a refused location is a `PermissionDenied` naming the check. Paper (`api.paper.novig.com`, `NOVIG_ENV=paper`) is the same API with play money and no location check. -Ids and keys differ between the two. Novig's parlay RFQ, for registered -liquidity providers, is not supported yet. +Ids and keys differ between the two. + +Novig's parlay RFQ is supported from the liquidity provider's side: +`NovigPricer` answers rounds over the maker WebSocket or signed webhooks +(`/quote`, `/confirm`, `/ping`), handles the 1-second last look, and tracks +the 60% confirm rate; `NovigRFQ` reads the RFQs won and the collateral in +open parlays. It needs an LP account and Novig's OAuth market-maker token +(`NOVIG_RFQ_CLIENT_ID`, `NOVIG_RFQ_CLIENT_SECRET`), not a NOVIG-V3 key. +Asking for a parlay quote, the user's side, is not offered. See +[Novig parlay RFQ](trading.md#novig-parlay-rfq). **What you may do with it.** Smarkets' API terms allow viewing prices and betting on your own account; odds scraping is prohibited. Which countries @@ -539,7 +547,7 @@ onboarded firms. | `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | | `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | | `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `rfq` | yes | no | no | no | no | no | no | no | no | no on `NovigTrading`; liquidity providers price parlay RFQs with `NovigPricer` | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | | `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; diff --git a/docs/streaming.md b/docs/streaming.md index b1aedf9..bba2e3b 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -46,6 +46,8 @@ asyncio.run(main()) | `SmarketsMarketStream` | `api.smarkets.com` REST, polled (no public socket) | none; an optional login undelays the quotes | contract books and top of book at every poll (5 s by default), contract status every few polls | | `NovigMarketStream` | `api.novig.com/v3/ws` (or Paper's); without a key, the public REST book polled | a subaccount's trading key for the WebSocket (the handshake is signed); none to poll | books kept per order, top of book, the tape, market transitions (going in play voids resting orders) | | `NovigUserStream` | `api.novig.com/v3/ws` | a subaccount's trading key | orders (open, fill, cancel, reject) and positions of that subaccount | +| `NovigPricerStream` (`synpath.ws.novig_rfq`) | `api.novig.com/rfq/ws` (or QA's) | Novig's OAuth market-maker token and a registered pricer | parlay RFQ rounds, answered by a `NovigPricer`; the pricer's quotes, last looks and fills, as `VenueEvent`s ([Novig parlay RFQ](trading.md#novig-parlay-rfq)) | +| `NovigExecutionFeed` (`synpath.ws.novig_rfq`) | `api.novig.com/rfq/executions/feed` | Novig's OAuth market-maker token | every RFQ execution on Novig, naming no pricer; a jump in `offset` is a gap | | `MatchbookMarketStream` | `api.matchbook.com` REST, polled (Matchbook has no push API) | none | runner books, top of book and runner status at every poll (5 s by default) | | `ProphetXMarketStream` | the host `GET /websocket/connection-config` names (ProphetX's Talaria, Pusher protocol 7) | the Trading API key pair (every channel is private) | each line's ten best levels a side on every change, top of book, the tournament's tape of fills | | `ProphetXUserStream` | the same host | the Trading API key pair | orders on every change; a fill with each match | diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..4fef0eb 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -131,8 +131,8 @@ play. Production needs Novig's identity check and a US location outside Arizona, Michigan and Nevada, with a recent device geolocation from the Novig app (a VPN is refused); Paper is the same API with play money and no location check. `NovigTrading.echo()` proves the host, key, clock and signature in one -call. Novig's parlay RFQ, for registered liquidity providers, is not -supported yet. See [Novig](capabilities.md#novig). +call. Liquidity providers can price Novig's parlay RFQs with `NovigPricer` +([Novig parlay RFQ](#novig-parlay-rfq)). See [Novig](capabilities.md#novig). ```python from decimal import Decimal @@ -882,3 +882,110 @@ schedule with one public request. Combo RFQ (Gemini's sealed WebSocket auction for multi-leg combos, in beta and run by market makers) is not offered, and nor are WebSocket order placement and `cancelOnDisconnect`. + +## Novig parlay RFQ + +Novig sends every parlay a user builds (2 to 20 legs, as outcome ids) to +each registered liquidity provider's pricer as an RFQ. The auction lasts 3 +seconds; the lowest `price` wins (a tie goes to the larger `max_wager`), +and the winner gets a 1-second last look at the user's actual stake to +confirm or reject. `synpath.trading.novig_rfq.NovigPricer` is that pricer: +you supply the pricing callback, and it answers over the maker WebSocket, +over webhooks, or both. Registering is self-serve but needs an LP account +(developers@novig.com), and the pricer is separate from order entry: the +RFQ routes take Novig's OAuth market-maker bearer token, not a NOVIG-V3 +key. + +```bash +NOVIG_RFQ_CLIENT_ID=... # the OAuth client credentials Novig issues to LPs +NOVIG_RFQ_CLIENT_SECRET=... # (or NOVIG_RFQ_ACCESS_TOKEN, a token used as it is) +NOVIG_RFQ_SHARED_SECRET=... # webhooks only: 64 hex characters, returned once by POST /rfq/pricer +NOVIG_RFQ_ENV=qa # production (the default) or qa; registrations are separate +``` + +```python +from decimal import Decimal + +from synpath.trading.credentials import load_novig_rfq +from synpath.trading.novig_rfq import NovigPricer, ParlayQuote + +async def price(rfq): # a ParlayRFQ: rfq_id, outcome_ids, min_wager, deadline() + if rfq.legs > 6: + return None # decline + return ParlayQuote(price=Decimal("0.18"), max_wager=Decimal("500"), quote_id="mm-1") + +pricer = NovigPricer(price, credentials=load_novig_rfq()) + +async with pricer.stream() as stream: # the maker WebSocket + async for event in stream: # every frame as a VenueEvent, and the stream's status + print(event) + +app = pricer.webhook_app(prefix="/novig") # or webhooks: POST /novig/quote, /confirm, /ping +``` + +**A round.** `price` gets each round as a `ParlayRFQ` and returns a +`ParlayQuote` or `None`, and may be a coroutine. `price` is the taker's +stake per $1 of payout (0.18 means the taker stakes $0.18 to win $1.00, so +the pricer puts up $0.82 a dollar); it is rounded up onto Novig's 0.001 +grid, and a quote Novig would drop (a price outside 0.001 to 0.999, a +`max_wager` of zero, a `quote_id` over 256 bytes) is refused before it is +sent. The callback has until the auction closes, less `margin` (0.25 s); a +slower answer, an exception, or a `max_wager` below the round's minimum +stake declines. On the socket a decline is sent as `decline`, on a webhook +as `204`. A round reaching both transports is priced once and Novig keeps +the first quote. + +**The last look.** `confirm` gets each win as a `LastLook` (the stake, +the price, and the pricer's own round and quote) and returns whether to +take it, within `confirm_timeout` (0.75 s) or the trade is rejected. +Without one, the pricer confirms a trade that matches the quote it sent: its +price, no more than its `max_wager`, its `quote_id`. + +**The confirm rate.** Novig locks a pricer out of quoting for 5 minutes +when it confirms fewer than 60% of its last looks over 30 minutes, once 20 +count. `pricer.confirm_rate` keeps the same count (`standing()`, +`would_lock(False)`, `rejects_left()`), records a lock from the socket's +`FORBIDDEN` error, and the pricer stops quoting while one is known. +`await pricer.sync_standing()` takes Novig's own count from +`GET /rfq/pricer`, which also sees the last looks this process did not. + +**Webhooks.** `webhook_app` checks `X-Novig-Signature` (a BLAKE3 keyed hash +of the raw body under the shared secret; the `blake3` package is used when +installed, a pure-Python version otherwise) on every request and answers a +bad one `401` without reading it. Serve it over public HTTPS at the base URL +you register. `handle_webhook(route, raw_body, signature)` is the same logic +for another web framework. + +**Executions and collateral.** `NovigRFQ` reads the RFQs won +(`fetch_executions`, newest first, paged with `after`; `iter_executions`), +catches up after a gap (`reconcile_executions(known_ids)`, paging from the +newest until a held id), and the collateral held in open parlays +(`fetch_collateral`). `NovigExecutionFeed` is the public tape of every RFQ +execution, marking a jump in `offset` as a gap. The maker socket is never +replayed, so a reconnect is marked `reconcile_required`. + +```python +from synpath.trading.novig_rfq import NovigRFQ, settle + +async with NovigRFQ(load_novig_rfq()) as rfq: + print(await rfq.fetch_pricer()) # registration, confirm standing, any lock + for row in await rfq.fetch_executions(status="open"): + print(row.rfq_id, row.wager, row.price, row.liability) + print(await rfq.fetch_collateral()) + +settle("42", "0.42", [1, "0.5", 1]) # a voided leg at 0.5: the taker gets $50 of the $100 pot +``` + +**Settlement.** A parlay settles as one position: any losing leg settles it +at once as a loss, a voided leg counts at its fair market value, and the +taker gets the pot times the product of the leg values (rounded to three +places, at least 0.001). `settle` and `settlement_scalar` compute it; +`RFQExecution.pricer_payout` reads it off a settled execution. +`eligibility_problems` checks legs against Novig's published combination +rules (2 to 20 legs, no same-kind lines on one game, no NO side of a +yes-or-no market, ...), from leg details you supply; Novig changes the +blocked pairs over time, so its own answer is the RFQ arriving or not. + +`register_pricer(webhook_url=None)` and `ping_webhook()` are there for the +one-time setup; the shared secret is returned only by the first +registration with a webhook URL, so store it then. diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 6c31691..ae0d017 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -54,6 +54,12 @@ key, PEM, Ed25519 or P-256), NOVIG_ENV (production, the default, or paper), and optionally NOVIG_MANAGEMENT_KEY_ID / NOVIG_MANAGEMENT_PRIVATE_KEY_PATH (subaccounts and funding) + Novig parlay RFQ NOVIG_RFQ_CLIENT_ID, NOVIG_RFQ_CLIENT_SECRET (the liquidity + provider's OAuth client credentials), or NOVIG_RFQ_ACCESS_TOKEN; + NOVIG_RFQ_SHARED_SECRET (webhooks only; NOVIG_RFQ_SECRET is + read too), NOVIG_RFQ_ENV (production, the default, or qa), + NOVIG_RFQ_TOKEN_URL; read by `load_novig_rfq`, not + `load_credentials` Matchbook MATCHBOOK_USERNAME, MATCHBOOK_PASSWORD: betting logs in; market data needs no login ProphetX PROPHETX_ACCESS_KEY, PROPHETX_SECRET_KEY (the Trading API: @@ -445,6 +451,39 @@ def secrets(self) -> list[str]: return [pem.decode(errors="ignore") for pem in (self.private_key_pem, self.management_private_key_pem) if pem] +@dataclass(frozen=True, repr=False) +class NovigRFQCredentials: + """Novig's parlay RFQ, for a liquidity provider's pricer: separate from + the order-entry keys (`NovigCredentials`). + + The RFQ routes (`/rfq/pricer`, `/rfq/executions`, `/rfq/collateral`) and + both RFQ WebSockets take the OAuth 2.0 client-credentials bearer token + Novig issues to market makers, not a NOVIG-V3 signature. The client id + and secret are exchanged for a token (30 minutes) as needed; a token + obtained elsewhere can be given instead. The shared secret, returned + once by `POST /rfq/pricer` with a `webhookUrl`, checks the signature on + every webhook; a WebSocket-only pricer has none.""" + + client_id: str | None = None + client_secret: str | None = field(default=None, repr=False) + access_token: str | None = field(default=None, repr=False) + """A bearer token used as it is, instead of the client credentials.""" + shared_secret: str | None = field(default=None, repr=False) + """64 hex characters: the BLAKE3 key the webhooks are signed with.""" + env: Literal["production", "qa"] = "production" + """`production` at api.novig.com; `qa` at api-qa.novig.us. Registrations + are separate in each.""" + token_url: str | None = None + """Where the client credentials are exchanged; the environment's + `/nbx/v1/auth/emm-token` by default.""" + _public = ("client_id", "env", "token_url") + __repr__ = _redacted_repr + + @property + def secrets(self) -> list[str]: + return [s for s in (self.client_secret, self.access_token, self.shared_secret) if s] + + @dataclass(frozen=True, repr=False) class MatchbookCredentials: """Matchbook: the account's login. Betting and the account's reads (offers, @@ -944,6 +983,40 @@ def load_novig(env: Mapping[str, str]) -> NovigCredentials | None: ) +NOVIG_RFQ_ENVS = ("production", "qa") + + +def load_novig_rfq( + env: Mapping[str, str] | None = None, *, dotenv: Path | str | None = None, redact_logs: bool = True, +) -> NovigRFQCredentials | None: + """A Novig pricer's credentials from the environment (and `.env`), or + `None` when none are set. Not part of `load_credentials`: the pricer is + a liquidity provider's service, not an order-entry adapter.""" + merged = merged_env(env, dotenv=dotenv) + client_id = (merged.get("NOVIG_RFQ_CLIENT_ID") or "").strip() or None + client_secret = (merged.get("NOVIG_RFQ_CLIENT_SECRET") or "").strip() or None + token = (merged.get("NOVIG_RFQ_ACCESS_TOKEN") or "").strip() or None + shared = (merged.get("NOVIG_RFQ_SHARED_SECRET") or merged.get("NOVIG_RFQ_SECRET") or "").strip() or None + if not (client_id or client_secret or token or shared): + return None + if bool(client_id) != bool(client_secret): + raise CredentialsMissing("Novig's RFQ client credentials are a pair: set both NOVIG_RFQ_CLIENT_ID and " + "NOVIG_RFQ_CLIENT_SECRET (issued by Novig to liquidity providers)") + if shared is not None and not re.match(r"^[0-9a-fA-F]{64}$", shared): + raise CredentialsMissing(f"NOVIG_RFQ_SHARED_SECRET must be the 64 hex characters POST /rfq/pricer returned; " + f"got {_shape_of(shared)}") + stage = (merged.get("NOVIG_RFQ_ENV") or "production").strip().lower() + if stage not in NOVIG_RFQ_ENVS: + raise CredentialsMissing(f"NOVIG_RFQ_ENV must be production or qa, got {stage!r}") + creds = NovigRFQCredentials( + client_id=client_id, client_secret=client_secret, access_token=token, shared_secret=shared.lower() if shared else None, + env=stage, token_url=(merged.get("NOVIG_RFQ_TOKEN_URL") or "").strip() or None, # type: ignore[arg-type] + ) + if redact_logs: + SecretFilter.install(creds.secrets) + return creds + + def load_matchbook(env: Mapping[str, str]) -> MatchbookCredentials | None: username, password = env.get("MATCHBOOK_USERNAME"), env.get("MATCHBOOK_PASSWORD") if not username and not password: diff --git a/synpath/trading/novig.py b/synpath/trading/novig.py index aa98561..7809315 100644 --- a/synpath/trading/novig.py +++ b/synpath/trading/novig.py @@ -384,7 +384,7 @@ class NovigTrading(TradingExchange): "fetch_settlements": False, "fetch_queue_position": False, "fetch_fee_estimate": True, - # Novig's parlay RFQ is for registered liquidity providers; not here. + # Novig's parlay RFQ is priced by liquidity providers: `synpath.trading.novig_rfq.NovigPricer`. "rfq": False, "split_merge": False, "watch_orders": False, diff --git a/synpath/trading/novig_rfq.py b/synpath/trading/novig_rfq.py new file mode 100644 index 0000000..c2af389 --- /dev/null +++ b/synpath/trading/novig_rfq.py @@ -0,0 +1,1219 @@ +"""Novig's parlay RFQ, from the liquidity provider's side: a pricer. + +Built to Novig's RFQ documentation (docs.novig.com, API reference, RFQ). + +**A round.** A user on Novig builds a parlay of 2 to 20 legs (outcome ids) +and Novig sends an RFQ to every registered pricer. The auction stays open 3 +seconds; a pricer answers with a `price` (the taker's stake per $1 of +payout, so the parlay's probability as the pricer sees it) and a +`max_wager` (the largest stake it takes), or declines. The lowest price +wins, a tie going to the larger `max_wager`. When the user accepts, the +winner gets a 1-second last look at the user's actual stake and confirms or +rejects; silence rejects. The executed parlay settles as one position. + +**Two transports, one pricer.** `NovigPricer` holds the pricing callback, +the last-look policy and the confirm-rate tracker, and answers rounds from +either transport: + +* the maker WebSocket (`wss://api.novig.com/rfq/ws`), one outbound + connection: `NovigPricer.stream()` (see `synpath.ws.novig_rfq`); +* webhooks Novig calls (`POST {base}/quote`, `/confirm`, `/ping`), each + signed with `X-Novig-Signature`: `NovigPricer.webhook_app()` is a FastAPI + app serving the three, and `handle_webhook` the same logic for any other + framework. + +A round that reaches both is priced once: the second transport gets the +same answer, and Novig keeps the first quote it receives. + +**Confirm rate.** A pricer must confirm at least 60% of its last looks over +the past 30 minutes, judged once 20 count; below that Novig locks it out of +quoting for 5 minutes and starts counting again from zero. `ConfirmRate` +keeps the same count locally, so a pricer can see a lock coming, and the +pricer stops quoting while one is known. + +**Auth.** The RFQ routes and sockets take the OAuth 2.0 client-credentials +bearer token Novig issues to market makers (`NovigRFQCredentials`), not the +NOVIG-V3 order-entry signature. The webhook signature is a BLAKE3 keyed +hash with the pricer's shared secret (`novig_rfq_signing`). + +**Reads.** `NovigRFQ` reads the registration and confirm standing +(`GET /rfq/pricer`), the RFQs won (`GET /rfq/executions`, paged, and +`reconcile_executions` to catch up after a gap) and the collateral held in +open parlays (`GET /rfq/collateral`). Registration (`register_pricer`) and +the webhook test (`ping_webhook`) are there for the operator to run once. + +Amounts are Novig Cash in `Decimal`; Novig sends and takes them as strings. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +import math +import re +import time +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from decimal import ROUND_CEILING, ROUND_DOWN, ROUND_HALF_UP, Decimal +from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, Sequence + +import httpx + +from ..base import AsyncHttpClient +from ..errors import AuthenticationError, MarketNotFound +from . import novig_rfq_signing as signing +from .credentials import NovigRFQCredentials +from .errors import CredentialsMissing, InvalidOrder, PermissionDenied + +log = logging.getLogger("synpath.trading.novig_rfq") + +VENUE = "novig" + +API_URLS = {"production": "https://api.novig.com", "qa": "https://api-qa.novig.us"} +WS_URLS = {"production": "wss://api.novig.com/rfq/ws", "qa": "wss://api-qa.novig.us/rfq/ws"} +FEED_URLS = {"production": "wss://api.novig.com/rfq/executions/feed", "qa": "wss://api-qa.novig.us/rfq/executions/feed"} +TOKEN_PATH = "/nbx/v1/auth/emm-token" +"""Where the client credentials are exchanged for a bearer token.""" + +TOKEN_LIFETIME = 30 * 60.0 +"""Seconds a token lasts when the answer does not say.""" +TOKEN_REFRESH_EARLY = 120.0 + +AUCTION_SECONDS = 3.0 +LAST_LOOK_SECONDS = 1.0 +QUOTE_LIFETIME_PREGAME = 30.0 +QUOTE_LIFETIME_LIVE = 10.0 +"""Seconds a winning quote stays acceptable after the auction closes.""" + +MIN_WAGER = Decimal("10.00") +"""The platform minimum: a quote whose `max_wager` is below it, or below the +taker's `min_wager`, is passed over.""" +MIN_LEGS, MAX_LEGS = 2, 20 +PRICE_STEP = Decimal("0.001") +PRICE_MIN, PRICE_MAX = Decimal("0.001"), Decimal("0.999") +QUOTE_ID_MAX_BYTES = 256 + +CONFIRM_THRESHOLD = Decimal("0.60") +CONFIRM_LOOKBACK = 30 * 60.0 +CONFIRM_MIN_SAMPLE = 20 +LOCK_SECONDS = 5 * 60.0 + +EXECUTIONS_PAGE = 500 +CENT = Decimal("0.01") +ZERO, ONE = Decimal("0"), Decimal("1") + +Transport = Literal["websocket", "webhook"] + + +def D(value: Any) -> Decimal: + if value in (None, ""): + return ZERO + return Decimal(str(value)) + + +def maybe_D(value: Any) -> Decimal | None: + return None if value in (None, "") else Decimal(str(value)) + + +_FRACTION = re.compile(r"\.(\d+)") + + +def ms_of(value: Any) -> int | None: + """An RFC 3339 time as Unix milliseconds; `None` when absent or unreadable.""" + if value in (None, ""): + return None + if isinstance(value, (int, float)): + return int(value) + text = str(value).strip().replace("Z", "+00:00").replace("z", "+00:00") + # Python 3.10 reads three or six fractional digits only. + text = _FRACTION.sub(lambda m: "." + (m.group(1) + "000000")[:6], text, count=1) + try: + moment = datetime.fromisoformat(text) + except ValueError: + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return int(moment.timestamp() * 1000) + + +def now_ms() -> int: + return int(time.time() * 1000) + + +# --------------------------------------------------------------------------- +# Rounds, quotes and last looks +# --------------------------------------------------------------------------- + +@dataclass(frozen=True, slots=True, kw_only=True) +class ParlayRFQ: + """One round: the legs a taker wants combined, and when it closes.""" + + rfq_id: str + outcome_ids: tuple[str, ...] + """One outcome id per leg, in the order the taker asked for them.""" + min_wager: Decimal = MIN_WAGER + """The smallest stake a quote must cover: the taker's minimum or the + platform's $10, whichever is higher.""" + expires_at: int | None = None + """When the auction closes, Unix ms (the venue's clock).""" + created_at: int | None = None + received_at: int = field(default_factory=now_ms) + transport: Transport = "websocket" + info: dict[str, Any] = field(default_factory=dict) + + @property + def legs(self) -> int: + return len(self.outcome_ids) + + def deadline(self) -> int: + """When an answer must be in, Unix ms on this machine's clock: the + auction's close, or 3 s after this round arrived when that is sooner.""" + local = self.received_at + int(AUCTION_SECONDS * 1000) + return min(local, self.expires_at) if self.expires_at else local + + @classmethod + def from_ws(cls, data: dict[str, Any], *, received_at: int | None = None) -> "ParlayRFQ": + """From the socket's `rfq_created`.""" + return cls( + rfq_id=str(data["rfq_id"]), outcome_ids=tuple(str(o) for o in data.get("outcome_ids") or ()), + min_wager=max(MIN_WAGER, D(data.get("min_wager"))), expires_at=ms_of(data.get("expires_at")), + received_at=received_at or now_ms(), transport="websocket", info=dict(data), + ) + + @classmethod + def from_webhook(cls, body: dict[str, Any], *, received_at: int | None = None) -> "ParlayRFQ": + """From a `/quote` request. `min_wager` is left out when the taker set + none; the platform minimum still applies. The auction closes 3 s + after `timestamp`.""" + created = ms_of(body.get("timestamp")) + return cls( + rfq_id=str(body["rfq_id"]), outcome_ids=tuple(str(o) for o in body.get("outcome_ids") or ()), + min_wager=max(MIN_WAGER, D(body.get("min_wager"))), created_at=created, + expires_at=created + int(AUCTION_SECONDS * 1000) if created else None, + received_at=received_at or now_ms(), transport="webhook", info=dict(body), + ) + + +def round_price(price: Any) -> Decimal: + """A price on Novig's RFQ grid of 0.001, rounded up (toward the pricer), + so the venue's own rounding to the nearest 0.001 never moves it.""" + value = D(price).quantize(PRICE_STEP, rounding=ROUND_CEILING) + return value + + +@dataclass(frozen=True, slots=True) +class ParlayQuote: + """A pricer's answer to a round. + + `price` is the taker's stake per $1 of payout: `0.45` means the taker + stakes $0.45 to win $1.00 back, so the pricer puts up $0.55. Lower wins. + `max_wager` is the largest taker stake taken at that price. `quote_id` + is the pricer's own reference (up to 256 bytes), handed back on the last + look as `external_flag`.""" + + price: Decimal + max_wager: Decimal + quote_id: str | None = None + + def checked(self) -> "ParlayQuote": + """The quote as it will be sent: the price on the 0.001 grid, and + refused (`InvalidOrder`) where Novig would drop it.""" + price = round_price(self.price) + if not PRICE_MIN <= price <= PRICE_MAX: + raise InvalidOrder(f"{VENUE}: RFQ price {self.price} is outside 0.001 to 0.999 after rounding") + wager = D(self.max_wager) + if wager <= 0: + raise InvalidOrder(f"{VENUE}: RFQ max_wager must be above zero, got {self.max_wager}") + if self.quote_id is not None and len(self.quote_id.encode("utf-8")) > QUOTE_ID_MAX_BYTES: + raise InvalidOrder(f"{VENUE}: RFQ quote_id is longer than {QUOTE_ID_MAX_BYTES} bytes") + return ParlayQuote(price=price, max_wager=wager, quote_id=self.quote_id) + + def ws_data(self, rfq_id: str) -> dict[str, Any]: + """`create_quote`'s data: every amount a decimal string.""" + data: dict[str, Any] = {"rfq_id": rfq_id, "price": str(self.price), "max_wager": str(self.max_wager)} + if self.quote_id is not None: + data["quote_id"] = self.quote_id + return data + + def webhook_body(self) -> dict[str, Any]: + """`/quote`'s answer: `price` a JSON number, `max_wager` a string.""" + body: dict[str, Any] = {"price": float(self.price), "max_wager": str(self.max_wager)} + if self.quote_id is not None: + body["quote_id"] = self.quote_id + return body + + def collateral(self, wager: Decimal | None = None) -> Decimal: + """What the pricer puts up against a stake (the whole `max_wager` + by default): the pot less the stake.""" + return collateral(self.max_wager if wager is None else D(wager), self.price) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class LastLook: + """A won quote the taker accepted: confirm or reject within 1 second.""" + + rfq_id: str + wager: Decimal + """The taker's actual stake; never above the quote's `max_wager`.""" + price: Decimal + """The price selected: the pricer's own.""" + quote_id: str | None = None + """Novig's id for the quote (the socket's `quote_accepted`); what the + socket's `confirm` names. Webhooks carry none.""" + external_flag: str | None = None + """The pricer's own `quote_id`, or `None` when it sent none.""" + transport: Transport = "websocket" + received_at: int = field(default_factory=now_ms) + rfq: ParlayRFQ | None = None + """The round, when this pricer saw it.""" + quote: ParlayQuote | None = None + """What this pricer quoted on the round, when it did.""" + info: dict[str, Any] = field(default_factory=dict) + + def deadline(self) -> int: + return self.received_at + int(LAST_LOOK_SECONDS * 1000) + + @property + def collateral(self) -> Decimal: + return collateral(self.wager, self.price) + + @classmethod + def from_ws(cls, data: dict[str, Any], *, received_at: int | None = None) -> "LastLook": + """From the socket's `quote_accepted`.""" + return cls( + rfq_id=str(data["rfq_id"]), wager=D(data.get("wager")), price=D(data.get("price")), + quote_id=str(data["quote_id"]) if data.get("quote_id") else None, + external_flag=data.get("external_flag"), transport="websocket", + received_at=received_at or now_ms(), info=dict(data), + ) + + @classmethod + def from_webhook(cls, body: dict[str, Any], *, received_at: int | None = None) -> "LastLook": + """From a `/confirm` request.""" + return cls( + rfq_id=str(body["rfq_id"]), wager=D(body.get("wager")), price=D(body.get("price")), + external_flag=body.get("external_flag"), transport="webhook", + received_at=received_at or now_ms(), info=dict(body), + ) + + +def matches_quote(look: LastLook) -> bool: + """The default last-look policy: confirm a trade on a quote this pricer + sent, at its price, for no more than its `max_wager`, under its own + `quote_id`. Anything else is rejected.""" + quote = look.quote + if quote is None: + return False + if look.wager <= 0 or look.wager > quote.max_wager: + return False + if round_price(look.price) != quote.price: + return False + if look.external_flag is not None and quote.quote_id is not None and look.external_flag != quote.quote_id: + return False + return True + + +# --------------------------------------------------------------------------- +# Money: the pot, collateral and settlement +# --------------------------------------------------------------------------- + +def pot(wager: Any, price: Any) -> Decimal: + """The parlay's pot, the stake plus the pricer's collateral: the stake + over the quoted price, rounded down to the cent.""" + return (D(wager) / D(price)).quantize(CENT, rounding=ROUND_DOWN) + + +def collateral(wager: Any, price: Any) -> Decimal: + """What the pricer puts up: the pot less the stake. The same figure as + an execution's `liability`.""" + return pot(wager, price) - D(wager) + + +LegValue = Decimal | float | int | str | None + + +def settlement_scalar(leg_values: Iterable[LegValue]) -> Decimal | None: + """What share of the pot goes to the taker, from each leg's graded value + (`1` a win, `0` a loss, a voided leg's fair market value) or `None` for a + leg not yet graded. + + Any loss settles the parlay at once, at `0`, whatever is still open; + otherwise an open leg leaves it open (`None`); otherwise the product of + the values, rounded to three decimals and never below `0.001`.""" + values = [None if v is None else D(v) for v in leg_values] + if any(v is not None and v == 0 for v in values): + return ZERO + if any(v is None for v in values): + return None + product = ONE + for value in values: + if not ZERO <= value <= ONE: # type: ignore[operator] + raise ValueError(f"a leg's value is between 0 and 1, got {value}") + product *= value # type: ignore[operator] + return max(Decimal("0.001"), product.quantize(PRICE_STEP, rounding=ROUND_HALF_UP)) + + +@dataclass(frozen=True, slots=True) +class ParlaySettlement: + result: Literal["open", "loss", "win", "fmv"] + """`loss`: the pricer takes the pot; `win`: the taker does; `fmv`: they + split it by `scalar` (a voided leg).""" + scalar: Decimal | None + pot: Decimal + taker_payout: Decimal | None + pricer_payout: Decimal | None + pricer_pnl: Decimal | None + """The pricer's payout less its collateral.""" + + +def settle(wager: Any, price: Any, leg_values: Iterable[LegValue]) -> ParlaySettlement: + """How an executed parlay pays, by Novig's rules from August 20, 2026: + the taker gets the pot times the scalar, the pricer the rest. The + taker's share is rounded down to the cent here; Novig does not publish + its rounding of a split pot.""" + whole = pot(wager, price) + scalar = settlement_scalar(leg_values) + if scalar is None: + return ParlaySettlement("open", None, whole, None, None, None) + taker = (whole * scalar).quantize(CENT, rounding=ROUND_DOWN) + pricer = whole - taker + result: Literal["loss", "win", "fmv"] = "loss" if scalar == 0 else "win" if scalar == 1 else "fmv" + return ParlaySettlement(result, scalar, whole, taker, pricer, pricer - (whole - D(wager))) + + +# --------------------------------------------------------------------------- +# Eligibility +# --------------------------------------------------------------------------- + +LINE_KINDS = {"moneyline": "side", "spread": "side", "total": "total"} +"""Lines of the same kind on one game do not combine: a spread with a +moneyline, two spreads, two totals.""" + +POWER_PLAY_CONFLICTS = frozenset({"points", "assists", "goals", "shots_on_goal"}) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ParlayLeg: + """What the eligibility rules look at in one leg. The RFQ itself carries + only outcome ids; a pricer fills these from its own catalog.""" + + outcome_id: str + event_id: str | None = None + """The game (or futures question) the leg is on.""" + league: str | None = None + market_type: str | None = None + """`moneyline`, `spread`, `total`, or a prop's type, like `player_points` + or `first_touchdown_scorer`.""" + period: str = "game" + """`game` or `first_half`: the same line on each half counts apart.""" + team_ids: tuple[str, ...] = () + """The teams the leg's event involves.""" + player_id: str | None = None + stats: frozenset[str] = frozenset() + """A player prop's stats: `{"points", "rebounds", "assists"}` for PRA.""" + future: bool = False + special: bool = False + """A special or promo market.""" + yes_no: bool = False + """A yes-or-no market (a future, a first scorer, a draw, a three-way + moneyline leg).""" + no_side: bool = False + """Whether the leg takes that market's NO.""" + open: bool = True + """Whether the leg's event is open, before the game or in play.""" + + +def eligibility_problems(legs: Sequence[ParlayLeg]) -> list[str]: + """Why Novig would refuse to send an RFQ for these legs, by its published + rules; empty when none apply. The venue's blocked pairs change over time, + so this is a pre-check, not the venue's answer, and a pricer can decline + any RFQ it receives.""" + problems: list[str] = [] + if not MIN_LEGS <= len(legs) <= MAX_LEGS: + problems.append(f"a parlay has {MIN_LEGS} to {MAX_LEGS} legs, not {len(legs)}") + seen: set[str] = set() + for leg in legs: + if leg.outcome_id in seen: + problems.append(f"outcome {leg.outcome_id} appears twice") + seen.add(leg.outcome_id) + if not leg.open: + problems.append(f"leg {leg.outcome_id}: its event is not open") + if leg.special: + problems.append(f"leg {leg.outcome_id}: special and promo markets cannot be legs") + if leg.yes_no and leg.no_side: + problems.append(f"leg {leg.outcome_id}: the NO side of a yes-or-no market cannot be a leg") + for i, a in enumerate(legs): + for b in legs[i + 1:]: + problems.extend(_pair_problems(a, b)) + return problems + + +def is_eligible(legs: Sequence[ParlayLeg]) -> bool: + return not eligibility_problems(legs) + + +def _pair_problems(a: ParlayLeg, b: ParlayLeg) -> list[str]: + pair = f"legs {a.outcome_id} and {b.outcome_id}" + out: list[str] = [] + same_game = a.event_id is not None and a.event_id == b.event_id + if same_game: + kind_a, kind_b = LINE_KINDS.get(a.market_type or ""), LINE_KINDS.get(b.market_type or "") + if kind_a and kind_a == kind_b and a.period == b.period: + out.append(f"{pair}: two {kind_a} lines on the same game") + if (a.market_type and a.market_type == b.market_type and "first" in a.market_type + and "scorer" in a.market_type): + out.append(f"{pair}: two first-scorer legs of the same type") + pp_a, pp_b = "power_play_points" in a.stats, "power_play_points" in b.stats + if (pp_a and b.stats & POWER_PLAY_CONFLICTS) or (pp_b and a.stats & POWER_PLAY_CONFLICTS): + out.append(f"{pair}: power play points with points, assists, goals or shots on goal") + if a.player_id is not None and a.player_id == b.player_id: + if a.market_type and a.market_type == b.market_type: + out.append(f"{pair}: two legs of the same market type on one player") + elif a.stats and b.stats and a.stats != b.stats and (a.stats <= b.stats or b.stats <= a.stats): + out.append(f"{pair}: one stat contains the other on one player") + if not same_game and a.event_id is not None and b.event_id is not None: + if set(a.team_ids) & set(b.team_ids): + out.append(f"{pair}: legs from different games share a team") + if (a.future or b.future) and a.league is not None and a.league == b.league: + out.append(f"{pair}: a future combines only with legs from other leagues") + return out + + +# --------------------------------------------------------------------------- +# Confirm rate +# --------------------------------------------------------------------------- + +@dataclass(frozen=True, slots=True) +class ConfirmStanding: + """Where a pricer stands against the confirm rate (`GET /rfq/pricer`).""" + + lookback_minutes: int = int(CONFIRM_LOOKBACK // 60) + counted: int = 0 + confirmed: int = 0 + threshold: Decimal = CONFIRM_THRESHOLD + min_sample: int = CONFIRM_MIN_SAMPLE + + @property + def rate(self) -> Decimal | None: + return Decimal(self.confirmed) / Decimal(self.counted) if self.counted else None + + @property + def judged(self) -> bool: + return self.counted >= self.min_sample + + @property + def below(self) -> bool: + rate = self.rate + return self.judged and rate is not None and rate < self.threshold + + def rejects_left(self) -> int: + """How many rejections (or misses) in a row this window takes before + the next one locks the pricer out; `0` when the next one would.""" + if self.below: + return 0 + rejects = 0 + while True: + counted = self.counted + rejects + 1 + if counted >= self.min_sample and Decimal(self.confirmed) / Decimal(counted) < self.threshold: + return rejects + rejects += 1 + + @classmethod + def from_row(cls, row: dict[str, Any] | None) -> "ConfirmStanding": + row = row or {} + return cls( + lookback_minutes=int(row.get("lookbackMinutes") or CONFIRM_LOOKBACK // 60), + counted=int(row.get("counted") or 0), confirmed=int(row.get("confirmed") or 0), + threshold=D(row.get("threshold") or CONFIRM_THRESHOLD), min_sample=int(row.get("minSample") or CONFIRM_MIN_SAMPLE), + ) + + +@dataclass(frozen=True, slots=True) +class PricerLock: + until: int | None + """Unix ms, or `None` for a lock with no set end.""" + source: str + """`confirm_rate`, or `admin` for one Novig set by hand.""" + + +class ConfirmRate: + """The confirm rate as Novig counts it, kept locally: every last look in + the past 30 minutes counts, a confirm for, a reject or a miss against. + Once 20 count, a rate below 60% locks the pricer out for 5 minutes, and + counting starts again from zero when the lock ends. + + Novig also counts last looks this process never saw (a confirm that + arrived late, a webhook that errored), so `sync` takes the venue's own + standing from `GET /rfq/pricer` when it is read.""" + + def __init__(self, *, threshold: Decimal = CONFIRM_THRESHOLD, lookback: float = CONFIRM_LOOKBACK, + min_sample: int = CONFIRM_MIN_SAMPLE, lock_seconds: float = LOCK_SECONDS, + clock: Callable[[], float] = time.time): + self.threshold = Decimal(str(threshold)) + self.lookback = lookback + self.min_sample = min_sample + self.lock_seconds = lock_seconds + self.clock = clock + self._looks: dict[str, tuple[float, bool]] = {} + self._base_counted = 0 + self._base_confirmed = 0 + self.lock: PricerLock | None = None + + def _now(self, at: float | None) -> float: + return self.clock() if at is None else at + + def _prune(self, now: float) -> None: + horizon = now - self.lookback + for key in [k for k, (t, _) in self._looks.items() if t < horizon]: + del self._looks[key] + + def record(self, rfq_id: str, confirmed: bool, *, at: float | None = None) -> None: + """One last look answered (`confirmed`), or rejected or missed.""" + now = self._now(at) + if self.locked(now): + return + self._prune(now) + self._looks[rfq_id] = (now, bool(confirmed)) + standing = self.standing(now) + if standing.below: + self.locked_out(until=int((now + self.lock_seconds) * 1000), source="confirm_rate", at=now) + + def amend(self, rfq_id: str, confirmed: bool) -> None: + """Correct an answer Novig counted otherwise: a confirm that reached + it after the window closed counts as a miss.""" + if rfq_id in self._looks: + stamp, _ = self._looks[rfq_id] + self._looks[rfq_id] = (stamp, bool(confirmed)) + + def standing(self, at: float | None = None) -> ConfirmStanding: + now = self._now(at) + self._prune(now) + counted = self._base_counted + len(self._looks) + confirmed = self._base_confirmed + sum(1 for _, ok in self._looks.values() if ok) + return ConfirmStanding(int(self.lookback // 60), counted, confirmed, self.threshold, self.min_sample) + + @property + def rate(self) -> Decimal | None: + return self.standing().rate + + def would_lock(self, confirmed: bool, *, at: float | None = None) -> bool: + """Whether answering the next last look this way would lock the pricer out.""" + standing = self.standing(at) + after = ConfirmStanding(standing.lookback_minutes, standing.counted + 1, + standing.confirmed + (1 if confirmed else 0), standing.threshold, standing.min_sample) + return after.below + + def locked(self, at: float | None = None) -> bool: + lock = self.lock + if lock is None: + return False + if lock.until is None: + return True + if self._now(at) * 1000 >= lock.until: + self.lock = None + return False + return True + + def locked_out(self, *, until: int | None, source: str = "confirm_rate", at: float | None = None) -> None: + """Novig locked the pricer out; counting starts from zero when it ends.""" + self.lock = PricerLock(until=until, source=source) + self._looks.clear() + self._base_counted = self._base_confirmed = 0 + + def sync(self, standing: ConfirmStanding | None, lock: PricerLock | None = None) -> None: + """Take Novig's own count as the base; last looks recorded here from + now on are added to it.""" + if standing is not None: + self._looks.clear() + self._base_counted, self._base_confirmed = standing.counted, standing.confirmed + self.threshold, self.min_sample = standing.threshold, standing.min_sample + self.lock = lock + + +_UNTIL = re.compile(r"until\s+(\d{4}-\d{2}-\d{2}T[0-9:.]+(?:Z|[+-]\d{2}:\d{2})?)") + + +def lock_of_message(message: str) -> PricerLock | None: + """A `FORBIDDEN` error's lock: `"... locked out of RFQ quoting until + 2026-10-06T18:28:00Z for a low confirm rate."`""" + if "locked out" not in (message or "").lower(): + return None + match = _UNTIL.search(message) + source = "confirm_rate" if "confirm rate" in message.lower() else "admin" + return PricerLock(until=ms_of(match.group(1)) if match else None, source=source) + + +# --------------------------------------------------------------------------- +# REST: registration, executions, collateral +# --------------------------------------------------------------------------- + +@dataclass(frozen=True, slots=True) +class PricerRegistration: + pricer_id: str + webhook_url: str | None + shared_secret: str | None = field(default=None, repr=False) + """Only `register_pricer` returns it, once; store it.""" + standing: ConfirmStanding | None = None + lock: PricerLock | None = None + info: dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "PricerRegistration": + lock = row.get("lock") + return cls( + pricer_id=str(row.get("pricerId") or ""), webhook_url=row.get("webhookUrl"), + shared_secret=row.get("sharedSecret"), + standing=ConfirmStanding.from_row(row["confirmStanding"]) if row.get("confirmStanding") else None, + lock=PricerLock(until=ms_of(lock.get("until")), source=str(lock.get("source") or "")) if lock else None, + info={k: v for k, v in row.items() if k != "sharedSecret"}, + ) + + +@dataclass(frozen=True, slots=True) +class RFQExecution: + """An RFQ this pricer won (`GET /rfq/executions`).""" + + rfq_id: str + parlay_id: str | None + wager: Decimal + price: Decimal + liability: Decimal + """The pricer's collateral on the parlay.""" + status: str + """`open` until the parlay settles, then `settled`.""" + result: str | None + """`win`, `loss`, `push` (before August 20, 2026) or `fmv`, once settled: + the taker's result.""" + fmv_value: Decimal | None + outcome_ids: tuple[str, ...] + """The legs, sorted by outcome id (not in the taker's order).""" + executed_at: int | None + settled_at: int | None + info: dict[str, Any] = field(default_factory=dict, repr=False) + + @property + def pot(self) -> Decimal: + return self.wager + self.liability + + @property + def pricer_payout(self) -> Decimal | None: + """What the pricer was paid from the pot, once settled. A `push` + refunded the stake, so the pricer got its collateral back.""" + if self.status != "settled" or self.result is None: + return None + if self.result == "loss": + return self.pot + if self.result == "win": + return ZERO + if self.result == "push": + return self.liability + if self.result == "fmv" and self.fmv_value is not None: + return self.pot - (self.pot * self.fmv_value).quantize(CENT, rounding=ROUND_DOWN) + return None + + @property + def pricer_pnl(self) -> Decimal | None: + paid = self.pricer_payout + return None if paid is None else paid - self.liability + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "RFQExecution": + return cls( + rfq_id=str(row.get("rfq_id") or ""), parlay_id=row.get("parlay_id"), wager=D(row.get("wager")), + price=D(row.get("price")), liability=D(row.get("liability")), status=str(row.get("status") or ""), + result=row.get("result"), fmv_value=maybe_D(row.get("fmv_value")), + outcome_ids=tuple(str(o) for o in row.get("outcome_ids") or ()), + executed_at=ms_of(row.get("executed_at")), settled_at=ms_of(row.get("settled_at")), info=dict(row), + ) + + +@dataclass(frozen=True, slots=True) +class RFQCollateral: + """The collateral in RFQ parlays that filled and have not settled.""" + + collateral: Decimal + rfqs_filled: int + timestamp: int | None + info: dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "RFQCollateral": + return cls(collateral=D(row.get("rfq_collateral")), rfqs_filled=int(row.get("rfqs_filled") or 0), + timestamp=ms_of(row.get("timestamp")), info=dict(row)) + + +class BearerToken: + """The market-maker bearer token: a fixed one, or one exchanged for the + client credentials and renewed before it expires.""" + + def __init__(self, credentials: NovigRFQCredentials, *, base_url: str, client: httpx.AsyncClient | None = None, + clock: Callable[[], float] = time.time): + self.credentials = credentials + self.url = credentials.token_url or f"{base_url.rstrip('/')}{TOKEN_PATH}" + self._client = client + self._token: str | None = credentials.access_token + self._expires = math.inf if credentials.access_token else 0.0 + self._lock = asyncio.Lock() + self.clock = clock + + @property + def fixed(self) -> bool: + return bool(self.credentials.access_token) + + def invalidate(self) -> None: + if not self.fixed: + self._token, self._expires = None, 0.0 + + async def get(self) -> str: + if self._token and self.clock() < self._expires - TOKEN_REFRESH_EARLY: + return self._token + async with self._lock: + if self._token and self.clock() < self._expires - TOKEN_REFRESH_EARLY: + return self._token + creds = self.credentials + if not (creds.client_id and creds.client_secret): + raise CredentialsMissing( + f"{VENUE}: the RFQ routes need a market-maker bearer token; set NOVIG_RFQ_CLIENT_ID and " + "NOVIG_RFQ_CLIENT_SECRET (or NOVIG_RFQ_ACCESS_TOKEN)" + ) + client = self._client or httpx.AsyncClient(timeout=30.0) + try: + response = await client.post(self.url, json={ + "grant_type": "client_credentials", "client_id": creds.client_id, + "client_secret": creds.client_secret, + }) + finally: + if self._client is None: + await client.aclose() + if response.status_code >= 400: + raise AuthenticationError(f"{VENUE}: the token endpoint refused the client credentials " + f"({response.status_code})", status=response.status_code) + body = response.json() + token = body.get("access_token") if isinstance(body, dict) else None + if not token: + raise AuthenticationError(f"{VENUE}: the token endpoint answered without an access_token") + lifetime = float(body.get("expires_in") or TOKEN_LIFETIME) + self._token, self._expires = str(token), self.clock() + lifetime + return self._token + + +class BearerAuth(httpx.Auth): + """Puts the bearer token on each request, and on a 401 renews it and + tries once more.""" + + def __init__(self, token: BearerToken): + self.token = token + + async def async_auth_flow(self, request: httpx.Request) -> AsyncIterator[httpx.Request]: # type: ignore[override] + request.headers["Authorization"] = f"Bearer {await self.token.get()}" + response = yield request + if response.status_code == 401 and not self.token.fixed: + self.token.invalidate() + request.headers["Authorization"] = f"Bearer {await self.token.get()}" + yield request + + +class NovigRFQ: + """Novig's RFQ REST routes for a pricer's account. + + ```python + from synpath.trading.credentials import load_novig_rfq + from synpath.trading.novig_rfq import NovigRFQ + + async with NovigRFQ(load_novig_rfq()) as rfq: + print(await rfq.fetch_pricer(), await rfq.fetch_collateral()) + ``` + """ + + def __init__(self, credentials: NovigRFQCredentials | None = None, *, env: str | None = None, + url: str | None = None, client: httpx.AsyncClient | None = None, + token_client: httpx.AsyncClient | None = None, timeout: float = 30.0): + credentials = credentials or NovigRFQCredentials() + self.credentials = credentials + self.env = env or credentials.env + if self.env not in API_URLS: + raise ValueError(f"{VENUE}: the RFQ env is production or qa, got {self.env!r}") + self.url = (url or API_URLS[self.env]).rstrip("/") + self.token = BearerToken(credentials, base_url=self.url, client=token_client) + shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True, + headers={"Accept": "application/json"}) + shared.auth = BearerAuth(self.token) + self._own_client = client is None + self.http = AsyncHttpClient(self.url, limiter=None, client=shared, venue=VENUE) + + async def __aenter__(self) -> "NovigRFQ": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.close() + + async def close(self) -> None: + if self._own_client: + await self.http._client.aclose() + + async def _call(self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None) -> Any: + query = {k: v for k, v in (params or {}).items() if v is not None} + try: + return await self.http.request(method, path, params=query or None, json=body) + except AuthenticationError as exc: + if exc.status == 403: + raise PermissionDenied( + f"{VENUE}: {path} refused (403): the account needs a registered pricer and an LP account " + "(developers@novig.com)", body=exc.body, status=exc.status) from None + raise + + # -- registration --------------------------------------------------------- + + async def fetch_pricer(self) -> PricerRegistration | None: + """The registration and confirm standing, without the secret; `None` + with no pricer registered.""" + try: + row = await self._call("GET", "/rfq/pricer") + except MarketNotFound: + return None + return PricerRegistration.from_row(row or {}) + + async def register_pricer(self, webhook_url: str | None = None) -> PricerRegistration: + """Register the account's pricer, or change its webhook URL. The + first call with a `webhook_url` returns the shared secret, once; a + first call without one registers a WebSocket-only pricer. A webhook + cannot be removed once registered.""" + body: dict[str, Any] = {} if webhook_url is None else {"webhookUrl": webhook_url} + return PricerRegistration.from_row(await self._call("POST", "/rfq/pricer", body=body) or {}) + + async def ping_webhook(self) -> dict[str, Any]: + """Have Novig send a signed request to `{webhook_url}/ping` and report + how it answered (`url`, `ok`, `status`, `error`). A successful ping + also restarts a paused webhook.""" + return dict(await self._call("POST", "/rfq/pricer/ping") or {}) + + # -- executions ----------------------------------------------------------- + + async def fetch_executions(self, *, status: Literal["open", "settled"] | None = None, + limit: int = EXECUTIONS_PAGE, after: str | None = None) -> list[RFQExecution]: + """One page of the RFQs this pricer won, newest first. `after` is the + last `rfq_id` of the previous page.""" + if not 1 <= limit <= EXECUTIONS_PAGE: + raise ValueError(f"{VENUE}: limit is 1 to {EXECUTIONS_PAGE}") + rows = await self._call("GET", "/rfq/executions", params={"status": status, "limit": limit, "after": after}) + return [RFQExecution.from_row(r) for r in rows or []] + + async def iter_executions(self, *, status: Literal["open", "settled"] | None = None, + page: int = EXECUTIONS_PAGE) -> AsyncIterator[RFQExecution]: + """Every execution, newest first, page by page.""" + after: str | None = None + while True: + rows = await self.fetch_executions(status=status, limit=page, after=after) + for row in rows: + yield row + if len(rows) < page: + return + after = rows[-1].rfq_id + + async def reconcile_executions(self, known: Iterable[str], *, page: int = 100, + max_pages: int = 50) -> list[RFQExecution]: + """The executions missing from `known` (rfq ids already held), newest + first: pages from the newest until one already held is reached, as + Novig's documentation recommends after a gap in the feed.""" + held = set(known) + missing: list[RFQExecution] = [] + after: str | None = None + for _ in range(max_pages): + rows = await self.fetch_executions(limit=page, after=after) + for row in rows: + if row.rfq_id in held: + return missing + missing.append(row) + if len(rows) < page: + return missing + after = rows[-1].rfq_id + return missing + + async def fetch_collateral(self) -> RFQCollateral: + return RFQCollateral.from_row(await self._call("GET", "/rfq/collateral") or {}) + + +# --------------------------------------------------------------------------- +# The pricer +# --------------------------------------------------------------------------- + +PriceFn = Callable[[ParlayRFQ], "ParlayQuote | None | Awaitable[ParlayQuote | None]"] +ConfirmFn = Callable[[LastLook], "bool | Awaitable[bool]"] + + +@dataclass +class _Round: + rfq: ParlayRFQ + answer: "asyncio.Future[ParlayQuote | None]" + at: float + + +@dataclass +class PricerStats: + rounds: int = 0 + quoted: int = 0 + declined: int = 0 + late: int = 0 + """Rounds the pricing callback did not answer in time.""" + errors: int = 0 + last_looks: int = 0 + confirmed: int = 0 + rejected: int = 0 + + +async def _maybe(value: Any) -> Any: + return await value if inspect.isawaitable(value) else value + + +class NovigPricer: + """Prices Novig parlay RFQs over the WebSocket, webhooks, or both. + + ```python + from decimal import Decimal + from synpath.trading.credentials import load_novig_rfq + from synpath.trading.novig_rfq import NovigPricer, ParlayQuote + + async def price(rfq): + if rfq.legs > 4: + return None # decline + return ParlayQuote(price=Decimal("0.30"), max_wager=Decimal("200")) + + pricer = NovigPricer(price, credentials=load_novig_rfq()) + async with pricer.stream() as stream: # the maker WebSocket + async for event in stream: + print(event) + + app = pricer.webhook_app() # or: uvicorn serves /quote, /confirm, /ping + ``` + + `price` gets each round as a `ParlayRFQ` and returns a `ParlayQuote`, or + `None` to decline; it may be a coroutine. It has until the auction + closes, less `margin` seconds for the answer's trip; a slower answer + declines. `confirm` gets each last look as a `LastLook`, with the + pricer's own quote on it, and returns whether to take the trade, within + `confirm_timeout` seconds or the trade is rejected. Without one, a trade + is confirmed when it matches the quote this pricer sent (`matches_quote`). + """ + + def __init__( + self, + price: PriceFn, + *, + confirm: ConfirmFn | None = None, + credentials: NovigRFQCredentials | None = None, + shared_secret: str | None = None, + env: str | None = None, + rfq: NovigRFQ | None = None, + margin: float = 0.25, + confirm_timeout: float = 0.75, + confirm_rate: ConfirmRate | None = None, + decline_below_min_wager: bool = True, + remember: float = 300.0, + ): + self.price = price + self.confirm = confirm + self.credentials = credentials or NovigRFQCredentials() + self.env = env or self.credentials.env + self.shared_secret = shared_secret or self.credentials.shared_secret + self._rfq = rfq + self.margin = margin + self.confirm_timeout = min(confirm_timeout, LAST_LOOK_SECONDS) + self.confirm_rate = confirm_rate or ConfirmRate() + self.decline_below_min_wager = decline_below_min_wager + self.remember = remember + self.stats = PricerStats() + self._rounds: dict[str, _Round] = {} + + @property + def rfq(self) -> NovigRFQ: + """The REST client, made on first use.""" + if self._rfq is None: + self._rfq = NovigRFQ(self.credentials, env=self.env) + return self._rfq + + async def close(self) -> None: + if self._rfq is not None: + await self._rfq.close() + + # -- rounds --------------------------------------------------------------- + + def _prune(self) -> None: + horizon = time.monotonic() - self.remember + for key in [k for k, r in self._rounds.items() if r.at < horizon]: + del self._rounds[key] + + def round_of(self, rfq_id: str) -> tuple[ParlayRFQ, ParlayQuote | None] | None: + """The round as this pricer saw it, and its quote (`None` declined).""" + held = self._rounds.get(rfq_id) + if held is None or not held.answer.done() or held.answer.cancelled(): + return None + return held.rfq, held.answer.result() + + async def quote(self, rfq: ParlayRFQ) -> ParlayQuote | None: + """The answer to a round: the pricing callback's quote, checked, or + `None` to decline. Priced once per `rfq_id`; a second transport + delivering the same round gets the same answer.""" + held = self._rounds.get(rfq.rfq_id) + if held is not None: + return await asyncio.shield(held.answer) + self._prune() + answer: asyncio.Future = asyncio.get_running_loop().create_future() + self._rounds[rfq.rfq_id] = _Round(rfq=rfq, answer=answer, at=time.monotonic()) + self.stats.rounds += 1 + try: + result = await self._price(rfq) + except BaseException: + answer.set_result(None) + raise + answer.set_result(result) + if result is None: + self.stats.declined += 1 + else: + self.stats.quoted += 1 + return result + + async def _price(self, rfq: ParlayRFQ) -> ParlayQuote | None: + if self.confirm_rate.locked(): + return None + budget = (rfq.deadline() - now_ms()) / 1000 - self.margin + if budget <= 0: + self.stats.late += 1 + return None + try: + quote = await asyncio.wait_for(_maybe(self.price(rfq)), timeout=budget) + except asyncio.TimeoutError: + self.stats.late += 1 + log.warning("novig rfq %s: the pricing callback took longer than %.2fs; declined", rfq.rfq_id, budget) + return None + except Exception: + self.stats.errors += 1 + log.exception("novig rfq %s: the pricing callback raised; declined", rfq.rfq_id) + return None + if quote is None: + return None + try: + quote = quote.checked() + except InvalidOrder as exc: + self.stats.errors += 1 + log.warning("novig rfq %s: %s; declined", rfq.rfq_id, exc) + return None + if self.decline_below_min_wager and quote.max_wager < rfq.min_wager: + log.info("novig rfq %s: max_wager %s is below the round's minimum %s; declined", + rfq.rfq_id, quote.max_wager, rfq.min_wager) + return None + return quote + + # -- last looks ----------------------------------------------------------- + + def with_round(self, look: LastLook) -> LastLook: + """The last look with this pricer's round and quote on it.""" + seen = self.round_of(look.rfq_id) + if seen is None: + return look + rfq, quote = seen + return replace(look, rfq=rfq, quote=quote) + + async def last_look(self, look: LastLook) -> bool: + """Whether to take a won trade: the confirm callback's answer within + `confirm_timeout`, or the default policy. A slow, failing or absent + answer rejects. Counted toward the confirm rate.""" + look = self.with_round(look) + self.stats.last_looks += 1 + budget = min(self.confirm_timeout, (look.deadline() - now_ms()) / 1000 - 0.05) + decision = False + if budget > 0: + try: + if self.confirm is None: + decision = matches_quote(look) + else: + decision = bool(await asyncio.wait_for(_maybe(self.confirm(look)), timeout=budget)) + except asyncio.TimeoutError: + log.warning("novig rfq %s: the confirm callback took longer than %.2fs; rejected", look.rfq_id, budget) + except Exception: + self.stats.errors += 1 + log.exception("novig rfq %s: the confirm callback raised; rejected", look.rfq_id) + self.confirm_rate.record(look.rfq_id, decision) + if decision: + self.stats.confirmed += 1 + else: + self.stats.rejected += 1 + return decision + + async def sync_standing(self) -> PricerRegistration | None: + """Read the registration and take Novig's confirm count and lock.""" + registration = await self.rfq.fetch_pricer() + if registration is not None: + self.confirm_rate.sync(registration.standing, registration.lock) + return registration + + # -- webhooks ------------------------------------------------------------- + + async def handle_webhook(self, route: str, raw_body: bytes, + signature: str | None) -> tuple[int, dict[str, Any] | None]: + """One webhook request as `(status, JSON body or None)`, for any web + framework. `route` is `quote`, `confirm` or `ping`; `raw_body` the + bytes exactly as received; `signature` the `X-Novig-Signature` + header. A bad signature is `401` and the body is never read.""" + if not self.shared_secret: + raise CredentialsMissing(f"{VENUE}: webhooks need the shared secret POST /rfq/pricer returned; " + "set NOVIG_RFQ_SHARED_SECRET") + if not signing.verify_webhook(self.shared_secret, raw_body, signature): + return 401, {"error": "invalid signature"} + received = now_ms() + name = route.strip("/").rsplit("/", 1)[-1] + if name == "ping": + return 200, {"ok": True} + try: + body = json.loads(raw_body) + except ValueError: + return 400, {"error": "invalid JSON"} + if not isinstance(body, dict) or "rfq_id" not in body: + return 400, {"error": "missing rfq_id"} + if name == "quote": + quote = await self.quote(ParlayRFQ.from_webhook(body, received_at=received)) + return (204, None) if quote is None else (200, quote.webhook_body()) + if name == "confirm": + return 200, {"confirmed": await self.last_look(LastLook.from_webhook(body, received_at=received))} + return 404, {"error": f"no route {route}"} + + def webhook_router(self) -> Any: + """A FastAPI router with `POST /quote`, `/confirm` and `/ping`, to + include under the path the webhook URL was registered with.""" + from fastapi import APIRouter + from fastapi.responses import JSONResponse, Response + + router = APIRouter() + + def endpoint_for(route: str) -> Callable[[Any], Awaitable[Any]]: + # A plain Starlette endpoint: the raw body is read, never parsed first. + async def endpoint(request: Any) -> Any: + status, body = await self.handle_webhook( + route, await request.body(), request.headers.get(signing.SIGNATURE_HEADER), + ) + return Response(status_code=status) if body is None else JSONResponse(body, status_code=status) + return endpoint + + for route in ("quote", "confirm", "ping"): + router.add_route(f"/{route}", endpoint_for(route), methods=["POST"], include_in_schema=False) + return router + + def webhook_app(self, *, prefix: str = "") -> Any: + """A FastAPI app serving the pricer's webhooks under `prefix` (the + path of the registered webhook URL, like `/novig`). Run it with + uvicorn behind HTTPS.""" + from fastapi import FastAPI + + app = FastAPI(title="Novig RFQ pricer", docs_url=None, redoc_url=None, openapi_url=None) + app.include_router(self.webhook_router(), prefix=prefix.rstrip("/")) + return app + + # -- the socket ----------------------------------------------------------- + + def stream(self, **kwargs: Any) -> Any: + """The maker WebSocket, answering rounds with this pricer: a + `synpath.ws.novig_rfq.NovigPricerStream`. Iterate it for every frame + as an event.""" + from ..ws.novig_rfq import NovigPricerStream + + return NovigPricerStream(self, **kwargs) diff --git a/synpath/trading/novig_rfq_signing.py b/synpath/trading/novig_rfq_signing.py new file mode 100644 index 0000000..90ba594 --- /dev/null +++ b/synpath/trading/novig_rfq_signing.py @@ -0,0 +1,187 @@ +"""Novig's parlay RFQ webhook signature (`X-Novig-Signature`), with no +network in sight. + +Every `/quote`, `/confirm` and `/ping` request Novig sends a pricer's +webhook carries + + X-Novig-Signature = hex(BLAKE3_keyed_hash(shared_secret_bytes, raw_body_bytes)) + +The key is the pricer's shared secret (64 lowercase hex characters from +`POST /rfq/pricer`, decoded to 32 bytes), the message is the request body +exactly as received, before any JSON parsing, and the output is 32 bytes as +64 lowercase hex characters. A request whose signature does not match is +answered `401` and never processed. + +BLAKE3 is not in the standard library. The `blake3` package is used when it +is installed; otherwise the keyed hash below, a direct transcription of the +BLAKE3 reference implementation, does the work. Webhook bodies are a few +hundred bytes, one chunk, so the pure version costs well under a millisecond. +It is checked against the official BLAKE3 test vectors +(`tests/samples/blake3_vectors.json`), and against the `blake3` package +where that is installed. +""" +from __future__ import annotations + +import hmac +import struct + +SIGNATURE_HEADER = "X-Novig-Signature" + +KEY_LEN = 32 +OUT_LEN = 32 +BLOCK_LEN = 64 +CHUNK_LEN = 1024 + +CHUNK_START = 1 << 0 +CHUNK_END = 1 << 1 +PARENT = 1 << 2 +ROOT = 1 << 3 +KEYED_HASH = 1 << 4 + +MASK = 0xFFFFFFFF +IV = (0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19) +PERMUTATION = (2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8) + + +# --------------------------------------------------------------------------- +# BLAKE3, keyed mode, 32-byte output +# --------------------------------------------------------------------------- + +def _g(s: list[int], a: int, b: int, c: int, d: int, x: int, y: int) -> None: + s[a] = (s[a] + s[b] + x) & MASK + v = s[d] ^ s[a] + s[d] = ((v >> 16) | (v << 16)) & MASK + s[c] = (s[c] + s[d]) & MASK + v = s[b] ^ s[c] + s[b] = ((v >> 12) | (v << 20)) & MASK + s[a] = (s[a] + s[b] + y) & MASK + v = s[d] ^ s[a] + s[d] = ((v >> 8) | (v << 24)) & MASK + s[c] = (s[c] + s[d]) & MASK + v = s[b] ^ s[c] + s[b] = ((v >> 7) | (v << 25)) & MASK + + +def _compress(cv: tuple[int, ...] | list[int], block: list[int], counter: int, block_len: int, + flags: int) -> list[int]: + s = [*cv[:8], *IV[:4], counter & MASK, (counter >> 32) & MASK, block_len, flags] + m = list(block) + for r in range(7): + _g(s, 0, 4, 8, 12, m[0], m[1]) + _g(s, 1, 5, 9, 13, m[2], m[3]) + _g(s, 2, 6, 10, 14, m[4], m[5]) + _g(s, 3, 7, 11, 15, m[6], m[7]) + _g(s, 0, 5, 10, 15, m[8], m[9]) + _g(s, 1, 6, 11, 12, m[10], m[11]) + _g(s, 2, 7, 8, 13, m[12], m[13]) + _g(s, 3, 4, 9, 14, m[14], m[15]) + if r < 6: + m = [m[i] for i in PERMUTATION] + for i in range(8): + s[i] ^= s[i + 8] + s[i + 8] ^= cv[i] + return s + + +def _words(block: bytes) -> list[int]: + return list(struct.unpack("<16I", block.ljust(BLOCK_LEN, b"\0"))) + + +class _Output: + """What a node would compress: its chaining value is the first eight + words; the root is the same compression with `ROOT` set.""" + + __slots__ = ("cv", "block", "counter", "block_len", "flags") + + def __init__(self, cv: tuple[int, ...] | list[int], block: list[int], counter: int, block_len: int, flags: int): + self.cv, self.block, self.counter, self.block_len, self.flags = cv, block, counter, block_len, flags + + def chaining_value(self) -> list[int]: + return _compress(self.cv, self.block, self.counter, self.block_len, self.flags)[:8] + + def root(self) -> bytes: + words = _compress(self.cv, self.block, 0, self.block_len, self.flags | ROOT)[:8] + return struct.pack("<8I", *words) + + +def _chunk(key: tuple[int, ...], chunk: bytes, counter: int, flags: int) -> _Output: + cv: tuple[int, ...] | list[int] = key + blocks = [chunk[i:i + BLOCK_LEN] for i in range(0, len(chunk), BLOCK_LEN)] or [b""] + for index, block in enumerate(blocks[:-1]): + start = CHUNK_START if index == 0 else 0 + cv = _compress(cv, _words(block), counter, BLOCK_LEN, flags | start)[:8] + last = blocks[-1] + start = CHUNK_START if len(blocks) == 1 else 0 + return _Output(cv, _words(last), counter, len(last), flags | start | CHUNK_END) + + +def _parent(left: list[int], right: list[int], key: tuple[int, ...], flags: int) -> _Output: + return _Output(key, [*left, *right], 0, BLOCK_LEN, flags | PARENT) + + +def blake3_keyed_hash_pure(key: bytes, data: bytes) -> bytes: + """BLAKE3 in keyed mode, 32 bytes out, in pure Python.""" + if len(key) != KEY_LEN: + raise ValueError(f"a BLAKE3 key is {KEY_LEN} bytes, got {len(key)}") + key_words = struct.unpack("<8I", key) + flags = KEYED_HASH + chunks = [data[i:i + CHUNK_LEN] for i in range(0, len(data), CHUNK_LEN)] or [b""] + stack: list[list[int]] = [] + output = _chunk(key_words, chunks[0], 0, flags) + for counter in range(1, len(chunks)): + cv = output.chaining_value() + total = counter + while total & 1 == 0: + cv = _parent(stack.pop(), cv, key_words, flags).chaining_value() + total >>= 1 + stack.append(cv) + output = _chunk(key_words, chunks[counter], counter, flags) + while stack: + output = _parent(stack.pop(), output.chaining_value(), key_words, flags) + return output.root() + + +def blake3_keyed_hash(key: bytes, data: bytes) -> bytes: + """BLAKE3 in keyed mode, 32 bytes out: the `blake3` package when it is + installed, the pure version otherwise.""" + try: + from blake3 import blake3 # type: ignore[import-not-found] + except ImportError: + return blake3_keyed_hash_pure(key, data) + if len(key) != KEY_LEN: + raise ValueError(f"a BLAKE3 key is {KEY_LEN} bytes, got {len(key)}") + return blake3(data, key=key).digest() + + +# --------------------------------------------------------------------------- +# The webhook signature +# --------------------------------------------------------------------------- + +def secret_bytes(shared_secret: str | bytes) -> bytes: + """The shared secret as the 32-byte key: 64 hex characters as Novig + returns it, or the raw 32 bytes.""" + if isinstance(shared_secret, bytes) and len(shared_secret) == KEY_LEN: + return shared_secret + text = shared_secret.decode() if isinstance(shared_secret, bytes) else shared_secret + try: + key = bytes.fromhex(text.strip()) + except ValueError: + raise ValueError("the Novig RFQ shared secret is 64 hex characters (32 bytes)") from None + if len(key) != KEY_LEN: + raise ValueError(f"the Novig RFQ shared secret is 64 hex characters (32 bytes), got {len(key)} bytes") + return key + + +def sign_webhook(shared_secret: str | bytes, raw_body: bytes) -> str: + """The `X-Novig-Signature` value for a body: what Novig sends, and what a + test sends to a local pricer.""" + return blake3_keyed_hash(secret_bytes(shared_secret), raw_body).hex() + + +def verify_webhook(shared_secret: str | bytes, raw_body: bytes, signature: str | None) -> bool: + """Whether `signature` is Novig's over these exact body bytes, compared + in constant time. A missing or malformed header is `False`.""" + if not signature: + return False + expected = sign_webhook(shared_secret, raw_body) + return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) diff --git a/synpath/ws/novig_rfq.py b/synpath/ws/novig_rfq.py new file mode 100644 index 0000000..f1900d2 --- /dev/null +++ b/synpath/ws/novig_rfq.py @@ -0,0 +1,251 @@ +"""Novig's parlay RFQ sockets: the maker WebSocket a pricer answers rounds +on, and the public tape of every RFQ execution. + +**The maker WebSocket** (`wss://api.novig.com/rfq/ws`, QA +`wss://api-qa.novig.us/rfq/ws`) takes the market-maker bearer token on the +upgrade and refuses an account with no pricer registration (`403`). Frames +are JSON `{event, data}`; Novig adds `timestamp` to its own. After every +connect the stream subscribes to both channels: `rfq` (`rfq_created`, +`rfq_closed`, `rfq_executed`, to every pricer) and `quotes` (`quote_created`, +`quote_accepted`, `quote_executed`, `quote_unfilled`, `quote_dropped`, to +this pricer alone). Without `quotes` no last look arrives and every win is +rejected. + +A round opens with `rfq_created`: the stream hands it to the pricer and +sends `create_quote` with the answer, or `decline`, before `expires_at`. A +win arrives as `quote_accepted`, answered with `confirm` within 1 second +under Novig's id for the quote. Every message sent gets one ack or one +`error`; an error never closes the connection. A `FORBIDDEN` error naming a +confirm-rate lock records the lock, and the pricer stops quoting until it +ends. + +Novig neither replays nor backfills: a reconnect starts at the live edge, +so it is marked `reconcile_required`; read the RFQs won from +`NovigRFQ.reconcile_executions`. Novig pings every 15 s and the WebSocket +library answers. + +Every frame is emitted as a `VenueEvent` named by its `event`, with `data` +as the payload, so a caller sees the rounds, its quotes' fates and the acks. + +**The execution feed** (`/rfq/executions/feed`) carries every RFQ execution +on Novig, naming no pricer, each with an `offset` that grows by one; a jump +is a gap (`StreamStatusEvent(state="gap", reconcile_required=True)`), filled +from `NovigRFQ.reconcile_executions`. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from ..trading import novig_rfq as rfq_api +from ..trading.credentials import NovigRFQCredentials +from ..trading.novig_rfq import LastLook, NovigPricer, ParlayRFQ, lock_of_message, ms_of +from .base import Event, Stream, VenueEvent, now_ms + +log = logging.getLogger("synpath.ws.novig_rfq") + +VENUE = "novig" +CHANNELS = ("rfq", "quotes") + +FINAL_STATUSES = { + 401: "the bearer token is missing or invalid", + 403: "not a market-maker token, or no pricer registered (POST /rfq/pricer first)", + 404: "the RFQ socket is off in this environment", +} +"""Upgrade refusals Novig says not to retry.""" + + +def _status_of(exc: BaseException) -> int | None: + response = getattr(exc, "response", None) + code = getattr(response, "status_code", None) or getattr(exc, "status_code", None) + return int(code) if code else None + + +class _Bearer(Stream): + """A socket authenticated by the market-maker bearer token on the upgrade.""" + + venue = VENUE + + def __init__(self, url: str, *, token: Any, **kwargs: Any): + self.token = token + self._bearer: str | None = None + super().__init__(url, **kwargs) + + def headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._bearer}"} if self._bearer else {} + + async def _open(self) -> Any: + try: + self._bearer = await self.token.get() + except Exception as exc: + self._fail(f"no bearer token: {exc}") + raise + try: + return await super()._open() + except Exception as exc: + code = _status_of(exc) + if code in FINAL_STATUSES: + if code == 401: + self.token.invalidate() + self._fail(f"upgrade refused ({code}): {FINAL_STATUSES[code]}") + raise + + def _fail(self, detail: str) -> None: + self._closing = True + self.status("failed", detail) + from .base import _CLOSED + + self._queue.put_nowait(_CLOSED) + + def _later(self, coroutine: Any) -> asyncio.Task | None: + try: + return asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + coroutine.close() + return None + + def _event(self, message: dict[str, Any]) -> VenueEvent: + data = message.get("data") + return VenueEvent(venue=VENUE, name=str(message.get("event") or ""), + payload=data if isinstance(data, dict) else {"data": data}, + timestamp=ms_of(message.get("timestamp"))) + + +class NovigPricerStream(_Bearer): + """The maker WebSocket, answering every round with a `NovigPricer`.""" + + name = "rfq" + private = True + + def __init__(self, pricer: NovigPricer, *, url: str | None = None, **kwargs: Any): + self.pricer = pricer + env = pricer.env + if env not in rfq_api.WS_URLS: + raise ValueError(f"{VENUE}: the RFQ env is production or qa, got {env!r}") + self._pricing: dict[str, asyncio.Task] = {} + self._confirms: dict[str, asyncio.Task] = {} + self._confirmed: dict[str, bool] = {} + super().__init__(url or rfq_api.WS_URLS[env], token=pricer.rfq.token, **kwargs) + + async def on_connect(self) -> None: + for channel in CHANNELS: + await self.send({"event": "subscribe", "data": channel}) + + def on_disconnect(self) -> None: + # A round being priced cannot be answered on the next connection. + for task in self._pricing.values(): + task.cancel() + self._pricing.clear() + + async def close(self) -> None: + for task in [*self._pricing.values(), *self._confirms.values()]: + task.cancel() + self._pricing.clear() + self._confirms.clear() + await super().close() + + # -- frames --------------------------------------------------------------- + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict): + return [] + name = message.get("event") + data = message.get("data") if isinstance(message.get("data"), dict) else {} + if name == "error": + self._error(data) + return [] + if name in ("subscribed", "unsubscribed"): + self.status("subscribed" if name == "subscribed" else "error", + f"{name} {data.get('channel', '')}".strip()) + return [] + if name == "rfq_created": + rfq = ParlayRFQ.from_ws(data, received_at=now_ms()) + task = self._later(self._answer(rfq)) + if task is not None: + self._pricing[rfq.rfq_id] = task + elif name == "rfq_closed": + task = self._pricing.pop(str(data.get("rfq_id")), None) + if task is not None and not task.done(): + task.cancel() + elif name == "quote_accepted": + look = LastLook.from_ws(data, received_at=now_ms()) + task = self._later(self._confirm(look)) + if task is not None: + self._confirms[look.rfq_id] = task + return [self._event(message)] + + def _error(self, data: dict[str, Any]) -> None: + code, text, rfq_id = str(data.get("code") or ""), str(data.get("message") or ""), data.get("rfq_id") + if code == "FORBIDDEN": + lock = lock_of_message(text) + if lock is not None: + self.pricer.confirm_rate.locked_out(until=lock.until, source=lock.source) + elif code == "NO_CONFIRM_WINDOW" and rfq_id and self._confirmed.get(str(rfq_id)): + # The confirm arrived after the window: Novig counts a miss. + self.pricer.confirm_rate.amend(str(rfq_id), False) + detail = f"novig refused: {code} {text}".strip() + self.status("error", detail + (f" (rfq {rfq_id})" if rfq_id else ""), key=str(rfq_id) if rfq_id else None) + + # -- answering ------------------------------------------------------------ + + async def _answer(self, rfq: ParlayRFQ) -> None: + try: + quote = await self.pricer.quote(rfq) + if quote is not None: + await self.send({"event": "create_quote", "data": quote.ws_data(rfq.rfq_id)}) + elif not self.pricer.confirm_rate.locked(): + # A decline says the pricer is up; silence looks like one that is down. + await self.send({"event": "decline", "data": {"rfq_id": rfq.rfq_id}}) + except asyncio.CancelledError: + raise + except Exception as exc: + self.status("error", f"answering rfq {rfq.rfq_id}: {type(exc).__name__}: {exc}", key=rfq.rfq_id) + finally: + self._pricing.pop(rfq.rfq_id, None) + + async def _confirm(self, look: LastLook) -> None: + try: + decision = await self.pricer.last_look(look) + self._confirmed[look.rfq_id] = decision + if len(self._confirmed) > 1000: + self._confirmed.pop(next(iter(self._confirmed))) + sent = await self.send({"event": "confirm", "data": { + "rfq_id": look.rfq_id, "quote_id": look.quote_id, "confirmed": decision, + }}) + if not sent and decision: + # The connection dropped before the answer: Novig rejects the trade. + self.pricer.confirm_rate.amend(look.rfq_id, False) + except asyncio.CancelledError: + raise + except Exception as exc: + self.status("error", f"confirming rfq {look.rfq_id}: {type(exc).__name__}: {exc}", key=look.rfq_id) + finally: + self._confirms.pop(look.rfq_id, None) + + +class NovigExecutionFeed(_Bearer): + """Every RFQ execution on Novig as it happens (`rfq_executed`), naming no + pricer. Needs a bearer token, not a pricer registration.""" + + name = "rfq_executions" + + def __init__(self, credentials: NovigRFQCredentials | None = None, *, env: str | None = None, + url: str | None = None, rfq: rfq_api.NovigRFQ | None = None, **kwargs: Any): + rfq = rfq or rfq_api.NovigRFQ(credentials, env=env) + self.rfq = rfq + self.last_offset: int | None = None + super().__init__(url or rfq_api.FEED_URLS[rfq.env], token=rfq.token, **kwargs) + + def handle(self, message: Any) -> list[Event]: + if not isinstance(message, dict) or message.get("event") != "rfq_executed": + return [] + data = message.get("data") if isinstance(message.get("data"), dict) else {} + offset = data.get("offset") + if isinstance(offset, int): + if self.last_offset is not None and offset > self.last_offset + 1: + self.status("gap", f"offsets {self.last_offset + 1} to {offset - 1} missed; " + "reconcile from NovigRFQ.reconcile_executions", reconcile=True) + if self.last_offset is None or offset > self.last_offset: + self.last_offset = offset + return [self._event(message)] diff --git a/tests/samples/blake3_vectors.json b/tests/samples/blake3_vectors.json new file mode 100644 index 0000000..cad2ff4 --- /dev/null +++ b/tests/samples/blake3_vectors.json @@ -0,0 +1,67 @@ +{ + "_source": "https://github.com/BLAKE3-team/BLAKE3/blob/master/test_vectors/test_vectors.json (the official BLAKE3 test vectors, trimmed to 32-byte outputs and a subset of lengths)", + "_input": "input_len bytes of the repeating sequence 0, 1, ..., 250, 0, 1, ...", + "key": "whats the Elvish word for friend", + "cases": [ + { + "input_len": 0, + "keyed_hash": "92b2b75604ed3c761f9d6f62392c8a9227ad0ea3f09573e783f1498a4ed60d26" + }, + { + "input_len": 1, + "keyed_hash": "6d7878dfff2f485635d39013278ae14f1454b8c0a3a2d34bc1ab38228a80c95b" + }, + { + "input_len": 63, + "keyed_hash": "bb1eb5d4afa793c1ebdd9fb08def6c36d10096986ae0cfe148cd101170ce37ae" + }, + { + "input_len": 64, + "keyed_hash": "ba8ced36f327700d213f120b1a207a3b8c04330528586f414d09f2f7d9ccb7e6" + }, + { + "input_len": 65, + "keyed_hash": "c0a4edefa2d2accb9277c371ac12fcdbb52988a86edc54f0716e1591b4326e72" + }, + { + "input_len": 1023, + "keyed_hash": "c951ecdf03288d0fcc96ee3413563d8a6d3589547f2c2fb36d9786470f1b9d6e" + }, + { + "input_len": 1024, + "keyed_hash": "75c46f6f3d9eb4f55ecaaee480db732e6c2105546f1e675003687c31719c7ba4" + }, + { + "input_len": 1025, + "keyed_hash": "357dc55de0c7e382c900fd6e320acc04146be01db6a8ce7210b7189bd664ea69" + }, + { + "input_len": 2048, + "keyed_hash": "879cf1fa2ea0e79126cb1063617a05b6ad9d0b696d0d757cf053439f60a99dd1" + }, + { + "input_len": 2049, + "keyed_hash": "9f29700902f7c86e514ddc4df1e3049f258b2472b6dd5267f61bf13983b78dd5" + }, + { + "input_len": 3072, + "keyed_hash": "044a0e7b172a312dc02a4c9a818c036ffa2776368d7f528268d2e6b5df191770" + }, + { + "input_len": 4097, + "keyed_hash": "00df940cd36bb9fa7cbbc3556744e0dbc8191401afe70520ba292ee3ca80abbc" + }, + { + "input_len": 8193, + "keyed_hash": "954a2a75420c8d6547e3ba5b98d963e6fa6491addc8c023189cc519821b4a1f5" + }, + { + "input_len": 16384, + "keyed_hash": "9e9fc4eb7cf081ea7c47d1807790ed211bfec56aa25bb7037784c13c4b707b0d" + }, + { + "input_len": 31744, + "keyed_hash": "efa53b389ab67c593dba624d898d0f7353ab99e4ac9d42302ee64cbf9939a419" + } + ] +} diff --git a/tests/samples/novig_rfq_docs.json b/tests/samples/novig_rfq_docs.json new file mode 100644 index 0000000..b5db0f1 --- /dev/null +++ b/tests/samples/novig_rfq_docs.json @@ -0,0 +1,248 @@ +{ + "_source": "Examples from Novig's RFQ documentation (docs.novig.com/api-reference/rfq: websocket, webhooks, registration, confirm-rate, executions), copied as published. Documented examples, not recorded traffic.", + "ws": { + "rfq_created": { + "event": "rfq_created", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "outcome_ids": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222" + ], + "expires_at": "2026-01-01T00:00:03Z", + "min_wager": "15" + }, + "timestamp": "2026-01-01T00:00:00.123Z" + }, + "rfq_closed": { + "event": "rfq_closed", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001" + }, + "timestamp": "2026-01-01T00:00:03.001Z" + }, + "rfq_executed": { + "event": "rfq_executed", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "wager": "50", + "price": "0.50", + "executed_at": "2023-11-14T22:13:20Z" + }, + "timestamp": "2023-11-14T22:13:20.050Z" + }, + "quote_created": { + "event": "quote_created", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff" + }, + "timestamp": "2026-01-01T00:00:03.001Z" + }, + "quote_accepted": { + "event": "quote_accepted", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "wager": "50", + "price": "0.55", + "external_flag": "mm-1" + }, + "timestamp": "2026-01-01T00:00:05.250Z" + }, + "quote_executed": { + "event": "quote_executed", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "wager": "50", + "price": "0.55", + "executed_at": "2023-11-14T22:13:20Z" + }, + "timestamp": "2023-11-14T22:13:20.050Z" + }, + "quote_unfilled": { + "event": "quote_unfilled", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "reason": "taker_insolvent" + }, + "timestamp": "2023-11-14T22:13:20.050Z" + }, + "quote_dropped": { + "event": "quote_dropped", + "data": { + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "pricer_trader_id": "0193abcd-0000-7000-8000-0000000000ff", + "reason": "below_min_wager", + "min_wager": "15" + }, + "timestamp": "2026-01-01T00:00:03.001Z" + }, + "subscribed": { + "event": "subscribed", + "data": { + "channel": "rfq" + }, + "timestamp": "2026-01-01T00:00:00.123Z" + }, + "quote_submitted": { + "event": "quote_submitted", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001" + }, + "timestamp": "2026-01-01T00:00:01.500Z" + }, + "confirm_recorded": { + "event": "confirm_recorded", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "quote_id": "0193abcd-0000-7000-8000-00000000000a" + }, + "timestamp": "2026-01-01T00:00:05.400Z" + }, + "error_rfq_closed": { + "event": "error", + "data": { + "code": "RFQ_CLOSED", + "message": "The quote round has already closed.", + "rfq_id": "0193abcd-0000-7000-8000-000000000001" + }, + "timestamp": "2026-01-01T00:00:03.100Z" + }, + "error_locked": { + "event": "error", + "data": { + "code": "FORBIDDEN", + "message": "Maker is locked out of RFQ quoting until 2026-10-06T18:28:00Z for a low confirm rate.", + "rfq_id": "0193abcd-0000-7000-8000-000000000002" + }, + "timestamp": "2026-10-06T18:24:10.000Z" + }, + "create_quote": { + "event": "create_quote", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "price": "0.45", + "max_wager": "250", + "quote_id": "mm-1" + } + }, + "confirm": { + "event": "confirm", + "data": { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "quote_id": "0193abcd-0000-7000-8000-00000000000a", + "confirmed": true + } + } + }, + "webhook": { + "quote_request": { + "rfq_id": "9b1d76e0-2fa9-0c1d-8e74-b3a5f6c218e0", + "outcome_ids": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222" + ], + "timestamp": "2026-04-29T18:42:11.123Z", + "min_wager": "50.00" + }, + "quote_response": { + "price": 0.45, + "max_wager": "250.00", + "quote_id": "pricer-quote-xyz" + }, + "confirm_request": { + "rfq_id": "9b1d76e0-2fa9-0c1d-8e74-b3a5f6c218e0", + "wager": "100.00", + "price": "0.45", + "external_flag": "pricer-quote-xyz" + }, + "confirm_response": { + "confirmed": true + }, + "ping_request": { + "pricer_id": "0193abcd-0000-7000-8000-000000000001", + "timestamp": "2026-04-29T18:42:11.123Z" + } + }, + "rest": { + "register_response": { + "pricerId": "0193abcd-0000-7000-8000-000000000001", + "webhookUrl": "https://pricer.example.com/novig", + "sharedSecret": null + }, + "pricer": { + "pricerId": "0193abcd-0000-7000-8000-000000000001", + "webhookUrl": "https://pricer.example.com/novig", + "confirmStanding": { + "lookbackMinutes": 30, + "counted": 34, + "confirmed": 29, + "threshold": "0.60", + "minSample": 20 + }, + "lock": null + }, + "pricer_locked": { + "pricerId": "0193abcd-0000-7000-8000-000000000001", + "webhookUrl": "https://pricer.example.com/novig", + "confirmStanding": { + "lookbackMinutes": 30, + "counted": 0, + "confirmed": 0, + "threshold": "0.60", + "minSample": 20 + }, + "lock": { + "until": "2026-10-06T18:28:00Z", + "source": "confirm_rate" + } + }, + "ping": { + "url": "https://pricer.example.com/novig/ping", + "ok": true, + "status": 200, + "error": null + }, + "executions": [ + { + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "parlay_id": "0193abcd-0000-7000-8000-0000000000aa", + "wager": "42.00", + "price": "0.42", + "liability": "58.00", + "status": "settled", + "result": "fmv", + "fmv_value": "0.541", + "outcome_ids": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222" + ], + "executed_at": "2026-06-16T22:13:20Z", + "settled_at": "2026-06-17T03:41:09Z" + } + ], + "collateral": { + "rfq_collateral": "300.00", + "rfqs_filled": 2, + "timestamp": "2026-09-23T21:36:32.123456Z" + }, + "feed_frame": { + "event": "rfq_executed", + "data": { + "offset": 12345, + "rfq_id": "0193abcd-0000-7000-8000-000000000001", + "wager": "50", + "price": "0.50", + "executed_at": "2026-06-16T22:13:20Z" + } + } + } +} diff --git a/tests/test_novig_rfq.py b/tests/test_novig_rfq.py new file mode 100644 index 0000000..eccae9d --- /dev/null +++ b/tests/test_novig_rfq.py @@ -0,0 +1,887 @@ +"""Novig's parlay RFQ, the pricer's side: the webhook signature against the +official BLAKE3 vectors, quotes and last looks, the pot and settlement, +eligibility, the confirm rate, the webhook handlers, the maker WebSocket +against a fake socket, and the REST reads against a scripted venue. Frames +and bodies are the examples in Novig's RFQ documentation +(`tests/samples/novig_rfq_docs.json`); nothing here reaches the venue.""" +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import httpx +import pytest + +from synpath.trading import novig_rfq_signing as signing +from synpath.trading.credentials import CredentialsMissing, NovigRFQCredentials, load_novig_rfq +from synpath.trading.errors import InvalidOrder, PermissionDenied +from synpath.trading.novig_rfq import ( + ConfirmRate, ConfirmStanding, LastLook, NovigPricer, NovigRFQ, ParlayLeg, ParlayQuote, ParlayRFQ, PricerLock, + PricerRegistration, RFQCollateral, RFQExecution, collateral, eligibility_problems, is_eligible, lock_of_message, + matches_quote, ms_of, pot, round_price, settle, settlement_scalar, +) +from synpath.ws.base import StreamStatusEvent, VenueEvent +from synpath.ws.novig_rfq import NovigExecutionFeed, NovigPricerStream + +from conftest import load + +DOCS = load("novig_rfq_docs.json") +WS, HOOK, REST = DOCS["ws"], DOCS["webhook"], DOCS["rest"] +VECTORS = load("blake3_vectors.json") +SECRET = "0f1e2d3c4b5a69788796a5b4c3d2e1f000112233445566778899aabbccddeeff" +"""A made-up shared secret: 64 hex characters, as Novig issues them.""" +RFQ_ID = WS["rfq_created"]["data"]["rfq_id"] +HOOK_RFQ = HOOK["quote_request"]["rfq_id"] + + +def vector_input(length: int) -> bytes: + return bytes(i % 251 for i in range(length)) + + +def iso(offset: float = 0.0) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=offset)).isoformat().replace("+00:00", "Z") + + +def fresh(frame: dict, **data) -> dict: + """A documented frame with its times moved to now.""" + out = json.loads(json.dumps(frame)) + out["data"].update(data) + return out + + +def token_creds(**changes) -> NovigRFQCredentials: + base = {"access_token": "test-token", "shared_secret": SECRET, "env": "qa"} + return NovigRFQCredentials(**{**base, **changes}) + + +def constant(price="0.45", wager="250", quote_id="mm-1"): + calls = [] + + async def price_fn(rfq): + calls.append(rfq) + return ParlayQuote(price=Decimal(price), max_wager=Decimal(wager), quote_id=quote_id) + + price_fn.calls = calls # type: ignore[attr-defined] + return price_fn + + +# --------------------------------------------------------------------------- +# The webhook signature +# --------------------------------------------------------------------------- + +class TestSignature: + @pytest.mark.parametrize("case", VECTORS["cases"], ids=lambda c: str(c["input_len"])) + def test_blake3_keyed_hash_matches_the_official_vectors(self, case): + key = VECTORS["key"].encode() + assert signing.blake3_keyed_hash_pure(key, vector_input(case["input_len"])).hex() == case["keyed_hash"] + + @pytest.mark.parametrize("case", VECTORS["cases"][:6], ids=lambda c: str(c["input_len"])) + def test_the_signature_is_the_keyed_hash_under_the_hex_secret(self, case): + secret_hex = VECTORS["key"].encode().hex() + body = vector_input(case["input_len"]) + assert signing.sign_webhook(secret_hex, body) == case["keyed_hash"] + assert signing.verify_webhook(secret_hex, body, case["keyed_hash"]) + + def test_the_installed_blake3_package_agrees_when_there_is_one(self): + blake3 = pytest.importorskip("blake3") + body = json.dumps(HOOK["quote_request"]).encode() + key = bytes.fromhex(SECRET) + assert blake3.blake3(body, key=key).digest() == signing.blake3_keyed_hash_pure(key, body) + + def test_a_body_signs_and_verifies_byte_for_byte(self): + raw = json.dumps(HOOK["quote_request"], separators=(",", ":")).encode() + signature = signing.sign_webhook(SECRET, raw) + assert len(signature) == 64 and signature == signature.lower() + assert signing.verify_webhook(SECRET, raw, signature) + assert signing.verify_webhook(SECRET, raw, signature.upper()) + # Re-serialized JSON is different bytes. + assert not signing.verify_webhook(SECRET, json.dumps(json.loads(raw), indent=1).encode(), signature) + assert not signing.verify_webhook(SECRET, raw + b" ", signature) + assert not signing.verify_webhook("ab" * 32, raw, signature) + assert not signing.verify_webhook(SECRET, raw, None) + assert not signing.verify_webhook(SECRET, raw, "") + + def test_the_secret_is_64_hex_characters(self): + assert signing.secret_bytes(SECRET) == bytes.fromhex(SECRET) + assert signing.secret_bytes(bytes.fromhex(SECRET)) == bytes.fromhex(SECRET) + for bad in ("abc", "zz" * 32, "ab" * 31): + with pytest.raises(ValueError): + signing.secret_bytes(bad) + + +# --------------------------------------------------------------------------- +# Rounds, quotes and last looks +# --------------------------------------------------------------------------- + +class TestRecords: + def test_a_round_from_the_socket(self): + rfq = ParlayRFQ.from_ws(WS["rfq_created"]["data"]) + assert rfq.rfq_id == RFQ_ID and rfq.legs == 2 and rfq.transport == "websocket" + assert rfq.min_wager == Decimal("15") + assert rfq.expires_at == ms_of("2026-01-01T00:00:03Z") + assert rfq.outcome_ids[0] == "11111111-1111-1111-1111-111111111111" + + def test_a_round_from_the_webhook_closes_three_seconds_after_it_was_made(self): + rfq = ParlayRFQ.from_webhook(HOOK["quote_request"]) + assert rfq.min_wager == Decimal("50.00") and rfq.transport == "webhook" + assert rfq.expires_at - rfq.created_at == 3000 + assert rfq.created_at == ms_of("2026-04-29T18:42:11.123Z") + + def test_without_a_minimum_the_platform_minimum_applies(self): + body = {k: v for k, v in HOOK["quote_request"].items() if k != "min_wager"} + assert ParlayRFQ.from_webhook(body).min_wager == Decimal("10.00") + assert ParlayRFQ.from_ws({**WS["rfq_created"]["data"], "min_wager": "5"}).min_wager == Decimal("10.00") + + def test_times(self): + assert ms_of("2026-09-23T21:36:32.123456Z") == ms_of("2026-09-23T21:36:32.123Z") + assert ms_of("2026-01-01T00:00:00.123456789Z") == ms_of("2026-01-01T00:00:00.123Z") + assert ms_of(None) is None and ms_of("nonsense") is None + + def test_prices_round_up_onto_the_grid(self): + assert round_price("0.4501") == Decimal("0.451") + assert round_price("0.45") == Decimal("0.450") + assert ParlayQuote(Decimal("0.12345"), Decimal("100")).checked().price == Decimal("0.124") + + @pytest.mark.parametrize("price,wager,quote_id", [ + ("0", "100", None), ("0.9995", "100", None), ("1.2", "100", None), ("0.5", "0", None), + ("0.5", "-1", None), ("0.5", "100", "x" * 257), + ]) + def test_quotes_novig_would_drop_are_refused(self, price, wager, quote_id): + with pytest.raises(InvalidOrder): + ParlayQuote(Decimal(price), Decimal(wager), quote_id).checked() + + def test_a_quote_on_each_transport(self): + quote = ParlayQuote(Decimal("0.45"), Decimal("250.00"), "pricer-quote-xyz").checked() + assert quote.webhook_body() == HOOK["quote_response"] + socket = ParlayQuote(Decimal("0.45"), Decimal("250"), "mm-1").checked().ws_data(RFQ_ID) + assert socket == {**WS["create_quote"]["data"], "price": "0.450"} + assert all(isinstance(socket[k], str) for k in ("price", "max_wager")) + assert "quote_id" not in ParlayQuote(Decimal("0.45"), Decimal("250")).checked().ws_data(RFQ_ID) + + def test_a_last_look_from_each_transport(self): + look = LastLook.from_ws(WS["quote_accepted"]["data"]) + assert look.quote_id == "0193abcd-0000-7000-8000-00000000000a" and look.external_flag == "mm-1" + assert look.wager == Decimal("50") and look.price == Decimal("0.55") + hook = LastLook.from_webhook(HOOK["confirm_request"]) + assert hook.quote_id is None and hook.external_flag == "pricer-quote-xyz" + assert hook.wager == Decimal("100.00") and hook.transport == "webhook" + assert hook.deadline() - hook.received_at == 1000 + + def test_the_default_policy_confirms_only_its_own_quote(self): + quote = ParlayQuote(Decimal("0.45"), Decimal("250"), "pricer-quote-xyz").checked() + look = LastLook.from_webhook(HOOK["confirm_request"]) + from dataclasses import replace + assert not matches_quote(look) + assert matches_quote(replace(look, quote=quote)) + assert not matches_quote(replace(look, quote=quote, wager=Decimal("250.01"))) + assert not matches_quote(replace(look, quote=quote, price=Decimal("0.44"))) + assert not matches_quote(replace(look, quote=quote, external_flag="someone-else")) + + +# --------------------------------------------------------------------------- +# The pot and settlement +# --------------------------------------------------------------------------- + +class TestSettlement: + def test_the_pot_and_collateral(self): + assert pot("42", "0.42") == Decimal("100.00") and collateral("42", "0.42") == Decimal("58.00") + assert pot("10", "0.3") == Decimal("33.33") and collateral("10", "0.3") == Decimal("23.33") + + @pytest.mark.parametrize("values,scalar", [ + ([0, None, None], Decimal("0")), ([0, "0.5", 1], Decimal("0")), ([1, "0.5", 1], Decimal("0.5")), + (["0.5", "0.6", 1], Decimal("0.3")), ([1, 1, 1], Decimal("1")), ([1, None, 1], None), + (["0.01", "0.01"], Decimal("0.001")), + ]) + def test_the_documented_scalars(self, values, scalar): + assert settlement_scalar(values) == scalar + + def test_a_voided_leg_splits_the_pot(self): + out = settle("42", "0.42", [1, "0.5", 1]) + assert out.result == "fmv" and out.taker_payout == Decimal("50.00") and out.pricer_payout == Decimal("50.00") + assert out.pricer_pnl == Decimal("-8.00") + loss = settle("42", "0.42", [0, None]) + assert loss.result == "loss" and loss.pricer_payout == Decimal("100.00") and loss.pricer_pnl == Decimal("42.00") + assert settle("42", "0.42", [1, None]).result == "open" + + def test_an_execution_and_its_payout(self): + row = RFQExecution.from_row(REST["executions"][0]) + assert row.pot == Decimal("100.00") and row.liability == Decimal("58.00") + assert row.result == "fmv" and row.fmv_value == Decimal("0.541") + assert row.pricer_payout == Decimal("45.90") and row.pricer_pnl == Decimal("-12.10") + assert row.executed_at == ms_of("2026-06-16T22:13:20Z") and len(row.outcome_ids) == 2 + + +# --------------------------------------------------------------------------- +# Eligibility +# --------------------------------------------------------------------------- + +class TestEligibility: + def leg(self, n, **kw): + return ParlayLeg(outcome_id=f"o{n}", **kw) + + def test_two_to_twenty_legs(self): + assert eligibility_problems([self.leg(1)]) + assert is_eligible([self.leg(1, event_id="a"), self.leg(2, event_id="b")]) + assert eligibility_problems([self.leg(n, event_id=f"e{n}") for n in range(21)]) + + def test_leg_rules(self): + assert eligibility_problems([self.leg(1, special=True), self.leg(2)]) + assert eligibility_problems([self.leg(1, yes_no=True, no_side=True), self.leg(2)]) + assert not eligibility_problems([self.leg(1, yes_no=True), self.leg(2)]) + assert eligibility_problems([self.leg(1, open=False), self.leg(2)]) + assert eligibility_problems([self.leg(1), self.leg(1)]) + + def test_same_game_lines(self): + game = {"event_id": "g1"} + assert eligibility_problems([self.leg(1, market_type="spread", **game), self.leg(2, market_type="moneyline", **game)]) + assert eligibility_problems([self.leg(1, market_type="total", **game), self.leg(2, market_type="total", **game)]) + assert is_eligible([self.leg(1, market_type="spread", **game), self.leg(2, market_type="total", **game)]) + assert is_eligible([self.leg(1, market_type="spread", **game), + self.leg(2, market_type="spread", period="first_half", **game)]) + assert eligibility_problems([self.leg(1, market_type="first_touchdown_scorer", **game), + self.leg(2, market_type="first_touchdown_scorer", **game)]) + assert eligibility_problems([self.leg(1, stats=frozenset({"power_play_points"}), player_id="p1", **game), + self.leg(2, stats=frozenset({"goals"}), player_id="p2", **game)]) + + def test_same_player(self): + game = {"event_id": "g1", "player_id": "p1"} + assert eligibility_problems([self.leg(1, market_type="player_points", **game), + self.leg(2, market_type="player_points", **game)]) + assert eligibility_problems([ + self.leg(1, market_type="player_pra", stats=frozenset({"points", "rebounds", "assists"}), **game), + self.leg(2, market_type="player_points", stats=frozenset({"points"}), **game)]) + assert is_eligible([self.leg(1, market_type="player_rebounds", stats=frozenset({"rebounds"}), **game), + self.leg(2, market_type="player_points", stats=frozenset({"points"}), **game)]) + + def test_different_games(self): + assert eligibility_problems([self.leg(1, event_id="g1", team_ids=("a", "b")), + self.leg(2, event_id="g2", team_ids=("b", "c"))]) + assert eligibility_problems([self.leg(1, event_id="f1", future=True, league="NBA"), + self.leg(2, event_id="g2", league="NBA")]) + assert is_eligible([self.leg(1, event_id="f1", future=True, league="NBA"), + self.leg(2, event_id="g2", league="NFL")]) + + +# --------------------------------------------------------------------------- +# The confirm rate +# --------------------------------------------------------------------------- + +class Clock: + def __init__(self, at: float = 1_800_000_000.0): + self.at = at + + def __call__(self) -> float: + return self.at + + +def answered(rate: ConfirmRate, clock: Clock, confirms: int, rejects: int, start: int = 0) -> None: + for i in range(confirms + rejects): + rate.record(f"r{start + i}", i < confirms) + clock.at += 1 + + +class TestConfirmRate: + def test_not_judged_below_twenty(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + answered(rate, clock, 0, 19) + assert not rate.locked() and rate.standing().counted == 19 and not rate.standing().judged + + def test_below_sixty_percent_of_twenty_locks_for_five_minutes(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + answered(rate, clock, 12, 7) + assert rate.standing().rejects_left() == 1 and not rate.would_lock(False) + answered(rate, clock, 0, 1, start=19) + assert not rate.locked() and rate.standing().rate == Decimal("0.6") + assert rate.would_lock(False) and not rate.would_lock(True) and rate.standing().rejects_left() == 0 + answered(rate, clock, 0, 1, start=20) + assert rate.locked() and rate.lock.source == "confirm_rate" + assert rate.lock.until == int((clock.at - 1 + 300) * 1000) + # While locked nothing counts, and counting restarts from zero after. + answered(rate, clock, 0, 5, start=30) + clock.at += 300 + assert not rate.locked() and rate.standing().counted == 0 + + def test_only_the_last_thirty_minutes_count(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + answered(rate, clock, 0, 10) + clock.at += 30 * 60 + 1 + answered(rate, clock, 15, 0, start=10) + assert rate.standing().counted == 15 and rate.standing().rate == 1 + + def test_a_late_confirm_is_amended_to_a_miss(self): + clock = Clock() + rate = ConfirmRate(clock=clock) + rate.record("r1", True) + rate.amend("r1", False) + assert rate.standing().confirmed == 0 and rate.standing().counted == 1 + + def test_the_venues_standing(self): + registration = PricerRegistration.from_row(REST["pricer"]) + standing = registration.standing + assert standing == ConfirmStanding(30, 34, 29, Decimal("0.60"), 20) and registration.lock is None + assert standing.rate == Decimal(29) / Decimal(34) and not standing.below + assert standing.rejects_left() == 14 # 29 / 49 >= 0.6, 29 / 50 < 0.6 + locked = PricerRegistration.from_row(REST["pricer_locked"]) + assert locked.lock == PricerLock(until=ms_of("2026-10-06T18:28:00Z"), source="confirm_rate") + clock = Clock(ms_of("2026-10-06T18:24:10Z") / 1000) + rate = ConfirmRate(clock=clock) + rate.sync(locked.standing, locked.lock) + assert rate.locked() + clock.at = ms_of("2026-10-06T18:28:00Z") / 1000 + assert not rate.locked() + rate.sync(standing) + rate.record("next", False) + assert rate.standing().counted == 35 and rate.standing().confirmed == 29 + + def test_a_lock_from_the_socket_error(self): + lock = lock_of_message(WS["error_locked"]["data"]["message"]) + assert lock == PricerLock(until=ms_of("2026-10-06T18:28:00Z"), source="confirm_rate") + assert lock_of_message(WS["error_rfq_closed"]["data"]["message"]) is None + assert lock_of_message("Maker is locked out of RFQ quoting.") == PricerLock(until=None, source="admin") + + +# --------------------------------------------------------------------------- +# The pricer +# --------------------------------------------------------------------------- + +def live_rfq(rfq_id: str = RFQ_ID, transport: str = "websocket", **changes) -> ParlayRFQ: + data = {**WS["rfq_created"]["data"], "rfq_id": rfq_id, "expires_at": iso(3), **changes} + rfq = ParlayRFQ.from_ws(data) + from dataclasses import replace + return replace(rfq, transport=transport) + + +class TestPricer: + def test_a_round_is_priced_once_across_transports(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + a, b = await asyncio.gather(pricer.quote(live_rfq()), pricer.quote(live_rfq(transport="webhook"))) + assert a == b and a.price == Decimal("0.450") and len(price.calls) == 1 + assert pricer.round_of(RFQ_ID)[1] == a and pricer.stats.quoted == 1 + asyncio.run(go()) + + def test_a_slow_answer_declines(self): + async def go(): + async def slow(rfq): + await asyncio.sleep(1) + return ParlayQuote(Decimal("0.4"), Decimal("100")) + pricer = NovigPricer(slow, credentials=token_creds(), margin=0.25) + assert await pricer.quote(live_rfq(expires_at=iso(0.5))) is None + assert pricer.stats.late == 1 and pricer.stats.declined == 1 + asyncio.run(go()) + + def test_an_expired_round_is_not_priced(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + assert await pricer.quote(ParlayRFQ.from_ws(WS["rfq_created"]["data"])) is None + assert price.calls == [] + asyncio.run(go()) + + def test_a_failing_or_invalid_answer_declines(self): + async def go(): + def boom(rfq): + raise RuntimeError("model down") + pricer = NovigPricer(boom, credentials=token_creds()) + assert await pricer.quote(live_rfq()) is None and pricer.stats.errors == 1 + pricer = NovigPricer(lambda rfq: ParlayQuote(Decimal("1"), Decimal("100")), credentials=token_creds()) + assert await pricer.quote(live_rfq()) is None + asyncio.run(go()) + + def test_below_the_rounds_minimum_declines(self): + async def go(): + pricer = NovigPricer(constant(wager="14"), credentials=token_creds()) + assert await pricer.quote(live_rfq()) is None + pricer = NovigPricer(constant(wager="14"), credentials=token_creds(), decline_below_min_wager=False) + assert (await pricer.quote(live_rfq())).max_wager == Decimal("14") + asyncio.run(go()) + + def test_no_pricing_while_locked_out(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + pricer.confirm_rate.locked_out(until=None, source="admin") + assert await pricer.quote(live_rfq()) is None and price.calls == [] + asyncio.run(go()) + + def test_last_looks(self): + async def go(): + pricer = NovigPricer(constant(price="0.55", wager="250"), credentials=token_creds()) + await pricer.quote(live_rfq()) + assert await pricer.last_look(LastLook.from_ws(WS["quote_accepted"]["data"])) + await pricer.quote(live_rfq("second")) + big = LastLook.from_ws({**WS["quote_accepted"]["data"], "rfq_id": "second", "wager": "251"}) + assert not await pricer.last_look(big) + unknown = LastLook.from_ws({**WS["quote_accepted"]["data"], "rfq_id": "other"}) + assert not await pricer.last_look(unknown) + standing = pricer.confirm_rate.standing() + assert (standing.counted, standing.confirmed) == (3, 1) + assert (pricer.stats.confirmed, pricer.stats.rejected) == (1, 2) + asyncio.run(go()) + + def test_a_confirm_callback_sees_the_quote_and_a_slow_one_rejects(self): + async def go(): + seen = [] + + async def confirm(look): + seen.append(look) + return look.quote is not None and look.collateral < 50 + + pricer = NovigPricer(constant(price="0.55"), confirm=confirm, credentials=token_creds()) + await pricer.quote(live_rfq()) + assert await pricer.last_look(LastLook.from_ws(WS["quote_accepted"]["data"])) + assert seen[0].quote.price == Decimal("0.550") and seen[0].collateral == Decimal("40.90") + + async def slow(look): + await asyncio.sleep(2) + return True + + pricer = NovigPricer(constant(price="0.55"), confirm=slow, credentials=token_creds(), confirm_timeout=0.1) + await pricer.quote(live_rfq()) + assert not await pricer.last_look(LastLook.from_ws(WS["quote_accepted"]["data"])) + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# Webhooks +# --------------------------------------------------------------------------- + +def signed(body: dict) -> tuple[bytes, str]: + raw = json.dumps(body).encode() + return raw, signing.sign_webhook(SECRET, raw) + + +class TestWebhooks: + def body(self, **changes): + return {**HOOK["quote_request"], "timestamp": iso(), **changes} + + def test_a_bad_signature_is_401_and_never_priced(self): + async def go(): + price = constant() + pricer = NovigPricer(price, credentials=token_creds()) + raw, _ = signed(self.body()) + assert (await pricer.handle_webhook("quote", raw, "00" * 32))[0] == 401 + assert (await pricer.handle_webhook("quote", raw, None))[0] == 401 + assert price.calls == [] + asyncio.run(go()) + + def test_quote_confirm_and_ping(self): + async def go(): + pricer = NovigPricer(constant(quote_id="pricer-quote-xyz"), credentials=token_creds()) + raw, sig = signed(self.body()) + status, body = await pricer.handle_webhook("quote", raw, sig) + assert status == 200 and body == {"price": 0.45, "max_wager": "250", "quote_id": "pricer-quote-xyz"} + raw, sig = signed(HOOK["confirm_request"]) + assert await pricer.handle_webhook("/novig/confirm", raw, sig) == (200, {"confirmed": True}) + raw, sig = signed(HOOK["ping_request"]) + assert (await pricer.handle_webhook("ping", raw, sig))[0] == 200 + asyncio.run(go()) + + def test_a_decline_is_204(self): + async def go(): + pricer = NovigPricer(lambda rfq: None, credentials=token_creds()) + raw, sig = signed(self.body()) + assert await pricer.handle_webhook("quote", raw, sig) == (204, None) + raw, sig = signed(HOOK["confirm_request"]) + assert await pricer.handle_webhook("confirm", raw, sig) == (200, {"confirmed": False}) + asyncio.run(go()) + + def test_without_a_shared_secret_webhooks_cannot_run(self): + async def go(): + pricer = NovigPricer(constant(), credentials=token_creds(shared_secret=None)) + with pytest.raises(CredentialsMissing): + await pricer.handle_webhook("ping", b"{}", "x") + asyncio.run(go()) + + def test_the_app_under_the_registered_path(self): + from fastapi.testclient import TestClient + + pricer = NovigPricer(constant(quote_id="pricer-quote-xyz"), credentials=token_creds()) + client = TestClient(pricer.webhook_app(prefix="/novig/")) + raw, sig = signed(self.body()) + answer = client.post("/novig/quote", content=raw, headers={"X-Novig-Signature": sig}) + assert answer.status_code == 200 and answer.json()["max_wager"] == "250" + raw, sig = signed(HOOK["confirm_request"]) + assert client.post("/novig/confirm", content=raw, headers={"X-Novig-Signature": sig}).json() == {"confirmed": True} + raw, sig = signed(HOOK["ping_request"]) + assert client.post("/novig/ping", content=raw, headers={"X-Novig-Signature": sig}).status_code == 200 + assert client.post("/novig/ping", content=raw, headers={"X-Novig-Signature": "0" * 64}).status_code == 401 + assert client.post("/novig/quote", content=raw).status_code == 401 + declining = TestClient(NovigPricer(lambda rfq: None, credentials=token_creds()).webhook_app()) + raw, sig = signed(self.body()) + answer = declining.post("/quote", content=raw, headers={"X-Novig-Signature": sig}) + assert answer.status_code == 204 and answer.content == b"" + + +# --------------------------------------------------------------------------- +# The maker WebSocket +# --------------------------------------------------------------------------- + +class FakeSocket: + def __init__(self): + self.inbox: asyncio.Queue = asyncio.Queue() + self.sent: list = [] + self.closed = False + + async def recv(self): + item = await self.inbox.get() + if item is None: + raise ConnectionError("closed") + return item + + async def send(self, text): + self.sent.append(json.loads(text)) + + async def close(self): + self.closed = True + + def push(self, frame): + self.inbox.put_nowait(json.dumps(frame)) + + def events(self, name): + return [m for m in self.sent if m["event"] == name] + + +async def settle_(seconds: float = 0.05): + for _ in range(5): + await asyncio.sleep(0) + await asyncio.sleep(seconds) + + +def drain(stream) -> list: + out = [] + while not stream._queue.empty(): + out.append(stream._queue.get_nowait()) + return out + + +def socket_pricer(price=None, **kwargs): + socket = FakeSocket() + headers = [] + + async def connect(url, additional_headers=None): + headers.append((url, additional_headers)) + return socket + + pricer = NovigPricer(price or constant(price="0.55"), credentials=token_creds(), **kwargs) + return pricer, NovigPricerStream(pricer, connect=connect), socket, headers + + +class TestPricerStream: + def test_a_round_won_and_confirmed(self): + async def go(): + pricer, stream, socket, headers = socket_pricer() + stream.start() + await settle_() + assert headers == [("wss://api-qa.novig.us/rfq/ws", {"Authorization": "Bearer test-token"})] + assert [m["data"] for m in socket.events("subscribe")] == ["rfq", "quotes"] + socket.push(WS["subscribed"]) + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + assert socket.events("create_quote") == [{"event": "create_quote", "data": { + "rfq_id": RFQ_ID, "price": "0.550", "max_wager": "250", "quote_id": "mm-1"}}] + for name in ("quote_submitted", "rfq_closed", "quote_created", "quote_accepted"): + socket.push(WS[name]) + await settle_() + assert socket.events("confirm") == [{"event": "confirm", "data": { + "rfq_id": RFQ_ID, "quote_id": "0193abcd-0000-7000-8000-00000000000a", "confirmed": True}}] + socket.push(WS["confirm_recorded"]) + socket.push(WS["quote_executed"]) + await settle_() + names = [e.name for e in drain(stream) if isinstance(e, VenueEvent)] + assert names == ["rfq_created", "quote_submitted", "rfq_closed", "quote_created", "quote_accepted", + "confirm_recorded", "quote_executed"] + assert pricer.confirm_rate.standing().confirmed == 1 + await stream.close() + asyncio.run(go()) + + def test_a_declined_round_sends_decline(self): + async def go(): + pricer, stream, socket, _ = socket_pricer(lambda rfq: None) + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + assert socket.events("decline") == [{"event": "decline", "data": {"rfq_id": RFQ_ID}}] + assert socket.events("create_quote") == [] + await stream.close() + asyncio.run(go()) + + def test_a_win_with_the_wrong_terms_is_rejected(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + socket.push(fresh(WS["quote_accepted"], wager="999")) + await settle_() + assert socket.events("confirm")[0]["data"]["confirmed"] is False + await stream.close() + asyncio.run(go()) + + def test_the_auction_closing_stops_pricing(self): + async def go(): + async def slow(rfq): + await asyncio.sleep(5) + return ParlayQuote(Decimal("0.4"), Decimal("100")) + pricer, stream, socket, _ = socket_pricer(slow) + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + socket.push(WS["rfq_closed"]) + await settle_() + assert socket.events("create_quote") == [] and socket.events("decline") == [] + assert stream._pricing == {} + await stream.close() + asyncio.run(go()) + + def test_a_confirm_rate_lock_stops_quoting(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.start() + await settle_() + future = (datetime.now(timezone.utc) + timedelta(minutes=4)).strftime("%Y-%m-%dT%H:%M:%SZ") + socket.push(fresh(WS["error_locked"], + message=f"Maker is locked out of RFQ quoting until {future} for a low confirm rate.")) + await settle_() + assert pricer.confirm_rate.locked() and pricer.confirm_rate.lock.source == "confirm_rate" + errors = [e for e in drain(stream) if isinstance(e, StreamStatusEvent) and e.state == "error"] + assert errors and "FORBIDDEN" in errors[0].detail + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + assert socket.events("create_quote") == [] and socket.events("decline") == [] + await stream.close() + asyncio.run(go()) + + def test_a_confirm_too_late_counts_as_a_miss(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.start() + await settle_() + socket.push(fresh(WS["rfq_created"], expires_at=iso(3))) + await settle_() + socket.push(WS["quote_accepted"]) + await settle_() + assert pricer.confirm_rate.standing().confirmed == 1 + socket.push({"event": "error", "data": {"code": "NO_CONFIRM_WINDOW", "message": "late", + "rfq_id": RFQ_ID}, "timestamp": iso()}) + await settle_() + assert pricer.confirm_rate.standing().confirmed == 0 + await stream.close() + asyncio.run(go()) + + def test_a_refused_upgrade_is_final(self): + async def go(): + class Refused(Exception): + def __init__(self, code): + self.response = type("R", (), {"status_code": code})() + + async def connect(url, additional_headers=None): + raise Refused(403) + + pricer = NovigPricer(constant(), credentials=token_creds()) + stream = NovigPricerStream(pricer, connect=connect) + events = [e async for e in stream] + assert events[-1].state == "failed" and "register" in events[-1].detail.lower() + asyncio.run(go()) + + def test_a_reconnect_asks_for_reconciliation(self): + async def go(): + pricer, stream, socket, _ = socket_pricer() + stream.backoff = lambda attempt: 0.0 + stream.start() + await settle_() + socket.inbox.put_nowait(None) + await settle_() + connected = [e for e in drain(stream) if isinstance(e, StreamStatusEvent) and e.state == "connected"] + assert len(connected) == 2 and connected[1].reconcile_required + await stream.close() + asyncio.run(go()) + + +class TestExecutionFeed: + def test_offsets_and_gaps(self): + async def go(): + socket = FakeSocket() + + async def connect(url, additional_headers=None): + assert url == "wss://api-qa.novig.us/rfq/executions/feed" + return socket + + feed = NovigExecutionFeed(token_creds(), connect=connect) + feed.start() + await settle_() + frame = REST["feed_frame"] + socket.push(frame) + socket.push({**frame, "data": {**frame["data"], "offset": 12346}}) + socket.push({**frame, "data": {**frame["data"], "offset": 12349}}) + await settle_() + events = drain(feed) + assert [e.payload["offset"] for e in events if isinstance(e, VenueEvent)] == [12345, 12346, 12349] + gaps = [e for e in events if isinstance(e, StreamStatusEvent) and e.state == "gap"] + assert len(gaps) == 1 and gaps[0].reconcile_required and "12347 to 12348" in gaps[0].detail + await feed.close() + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# REST, against a scripted venue +# --------------------------------------------------------------------------- + +class Venue: + def __init__(self): + self.requests: list[httpx.Request] = [] + self.executions = [{**REST["executions"][0], "rfq_id": f"00000000-0000-7000-8000-{n:012d}"} for n in range(7, 0, -1)] + self.tokens = 0 + self.reject_token: str | None = None + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + if path == "/nbx/v1/auth/emm-token": + self.tokens += 1 + body = json.loads(request.content) + assert body == {"grant_type": "client_credentials", "client_id": "cid", "client_secret": "csecret"} + return httpx.Response(200, json={"access_token": f"token-{self.tokens}", "expires_in": 1800}) + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer ") or auth == f"Bearer {self.reject_token}": + return httpx.Response(401, json={"message": "invalid token"}) + if path == "/rfq/pricer" and request.method == "GET": + return httpx.Response(200, json=REST["pricer"]) + if path == "/rfq/executions": + limit = int(request.url.params.get("limit", 500)) + after = request.url.params.get("after") + rows = self.executions + if after: + ids = [r["rfq_id"] for r in rows] + rows = rows[ids.index(after) + 1:] if after in ids else [] + return httpx.Response(200, json=rows[:limit]) + if path == "/rfq/collateral": + return httpx.Response(200, json=REST["collateral"]) + return httpx.Response(404, json={"message": "not found"}) + + +def rest(venue: Venue, **creds) -> NovigRFQ: + credentials = NovigRFQCredentials(**({"client_id": "cid", "client_secret": "csecret", "env": "qa"} | creds)) + transport = httpx.MockTransport(venue) + return NovigRFQ(credentials, client=httpx.AsyncClient(transport=transport), + token_client=httpx.AsyncClient(transport=transport)) + + +class TestREST: + def test_the_token_is_exchanged_once_and_sent_as_a_bearer(self): + async def go(): + venue = Venue() + async with rest(venue) as rfq: + registration = await rfq.fetch_pricer() + collateral = await rfq.fetch_collateral() + assert venue.tokens == 1 + assert venue.requests[0].url == "https://api-qa.novig.us/nbx/v1/auth/emm-token" + assert all(r.headers["Authorization"] == "Bearer token-1" for r in venue.requests[1:]) + assert registration.pricer_id == REST["pricer"]["pricerId"] and registration.standing.counted == 34 + assert collateral == RFQCollateral(Decimal("300.00"), 2, ms_of("2026-09-23T21:36:32.123456Z"), + REST["collateral"]) + asyncio.run(go()) + + def test_a_refused_token_is_renewed_once(self): + async def go(): + venue = Venue() + venue.reject_token = "token-1" + async with rest(venue) as rfq: + assert (await rfq.fetch_pricer()) is not None + assert venue.tokens == 2 + asyncio.run(go()) + + def test_no_pricer_is_none_and_403_names_registration(self): + async def go(): + def answer(request): + if request.url.path == "/rfq/pricer": + return httpx.Response(404, json={"message": "no pricer"}) + return httpx.Response(403, json={"message": "forbidden"}) + client = httpx.AsyncClient(transport=httpx.MockTransport(answer)) + async with NovigRFQ(token_creds(), client=client) as rfq: + assert await rfq.fetch_pricer() is None + with pytest.raises(PermissionDenied, match="registered pricer"): + await rfq.fetch_collateral() + asyncio.run(go()) + + def test_executions_page_and_reconcile(self): + async def go(): + venue = Venue() + async with rest(venue) as rfq: + page = await rfq.fetch_executions(limit=3) + every = [row async for row in rfq.iter_executions(page=3)] + known = {venue.executions[4]["rfq_id"], venue.executions[5]["rfq_id"]} + missing = await rfq.reconcile_executions(known, page=2) + settled = await rfq.fetch_executions(status="settled", limit=2) + assert [r.rfq_id for r in page] == [r["rfq_id"] for r in venue.executions[:3]] + assert [r.rfq_id for r in every] == [r["rfq_id"] for r in venue.executions] + assert [r.rfq_id for r in missing] == [r["rfq_id"] for r in venue.executions[:4]] + sent = [r for r in venue.requests if r.url.path == "/rfq/executions"] + assert sent[1].url.params.get("after") is None and sent[2].url.params["after"] == venue.executions[2]["rfq_id"] + assert sent[-1].url.params["status"] == "settled" and len(settled) == 2 + with pytest.raises(ValueError): + await rfq.fetch_executions(limit=501) + asyncio.run(go()) + + def test_registration_and_ping_send_what_the_docs_show(self): + async def go(): + seen = [] + + def answer(request): + seen.append(request) + if request.url.path == "/rfq/pricer/ping": + return httpx.Response(200, json=REST["ping"]) + return httpx.Response(200, json={**REST["register_response"], "sharedSecret": SECRET}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(answer)) + async with NovigRFQ(token_creds(), client=client) as rfq: + registration = await rfq.register_pricer("https://pricer.example.com/novig") + socket_only = await rfq.register_pricer() + ping = await rfq.ping_webhook() + assert json.loads(seen[0].content) == {"webhookUrl": "https://pricer.example.com/novig"} + assert json.loads(seen[1].content) == {} + assert registration.shared_secret == SECRET and "sharedSecret" not in registration.info + assert SECRET not in repr(registration) and socket_only.pricer_id + assert ping["ok"] is True and seen[2].method == "POST" + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# Credentials +# --------------------------------------------------------------------------- + +class TestCredentials: + def test_from_the_environment(self, tmp_path): + env = {"NOVIG_RFQ_CLIENT_ID": "cid", "NOVIG_RFQ_CLIENT_SECRET": "csecret", + "NOVIG_RFQ_SHARED_SECRET": SECRET.upper(), "NOVIG_RFQ_ENV": "QA"} + creds = load_novig_rfq(env, dotenv=tmp_path / "none.env", redact_logs=False) + assert creds.client_id == "cid" and creds.shared_secret == SECRET and creds.env == "qa" + assert "csecret" not in repr(creds) and SECRET not in repr(creds) + docs_name = load_novig_rfq({"NOVIG_RFQ_SECRET": SECRET}, dotenv=tmp_path / "none.env", redact_logs=False) + assert docs_name.shared_secret == SECRET and docs_name.env == "production" + assert load_novig_rfq({}, dotenv=tmp_path / "none.env") is None + + @pytest.mark.parametrize("env", [ + {"NOVIG_RFQ_CLIENT_ID": "cid"}, + {"NOVIG_RFQ_ACCESS_TOKEN": "t", "NOVIG_RFQ_SHARED_SECRET": "short"}, + {"NOVIG_RFQ_ACCESS_TOKEN": "t", "NOVIG_RFQ_ENV": "paper"}, + ]) + def test_mistakes_are_named(self, env, tmp_path): + with pytest.raises(CredentialsMissing): + load_novig_rfq(env, dotenv=tmp_path / "none.env", redact_logs=False) + + def test_no_token_source_is_named_on_first_use(self): + async def go(): + async with NovigRFQ(NovigRFQCredentials(env="qa")) as rfq: + with pytest.raises(CredentialsMissing, match="NOVIG_RFQ_CLIENT_ID"): + await rfq.token.get() + asyncio.run(go()) From 47a7cca94622bbebf49bff2103d99642499044c3 Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 11:04:06 +0000 Subject: [PATCH 6/8] feat: azuro-combos-claim Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 7 +- docs/capabilities.md | 9 +- docs/trading.md | 71 +- synpath/azuro.py | 16 +- synpath/trading/azuro.py | 631 ++++++++++++++-- synpath/trading/azuro_signing.py | 166 +++- tests/samples/azuro_redeemable_bets.json | 923 +++++++++++++++++++++++ tests/samples/azuro_signing_vectors.json | 232 +++++- tests/test_azuro.py | 11 + tests/test_azuro_trading.py | 464 +++++++++++- 10 files changed, 2450 insertions(+), 80 deletions(-) create mode 100644 tests/samples/azuro_redeemable_bets.json diff --git a/docs/api.md b/docs/api.md index 4de4820..03db898 100644 --- a/docs/api.md +++ b/docs/api.md @@ -176,9 +176,11 @@ Azuro needs no key. It adds `fetch_sports()` (the feed's sports, countries and leagues with their open game counts), `fetch_limits(market_id, side)` (the pool's minimum and maximum bet and maximum payout on one selection) and `fetch_condition_states(condition_ids)` (fresh odds and states, without the -games), and takes `sport` and `league` (the feed's slugs, `"football"`, +games), `fetch_conditions(condition_ids)` (conditions by id with their game +and whether they may go in a combo, `isExpressForbidden`), and takes `sport` and `league` (the feed's slugs, `"football"`, `"premier-league"`) on `fetch_markets` and `fetch_events`. `chain=` picks the -pool read: `polygon` (the default), `base`, or a testnet. See +pool read: `polygon` (the default), `base`, or a testnet. Combos and claiming +payouts are on `AzuroTrading` ([trading](trading.md#azuro)). See [Azuro](capabilities.md#azuro). ```python @@ -188,6 +190,7 @@ market = markets[0] market.yes.quote.ask, market.info["odds"] # 1 / odds, and the venue's odds azuro.fetch_limits(market.id) # {"min_bet", "max_bet", "max_payout", ...} azuro.fetch_trades(market.id) # single bets, from the bets subgraph +azuro.fetch_conditions([market.info["condition_id"]]) # {id: condition}, isExpressForbidden and the game ``` Myriad needs no key (an API key and secret raise its rate limit). It takes diff --git a/docs/capabilities.md b/docs/capabilities.md index 4a19cb2..2f58c78 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -377,7 +377,8 @@ returns the feed's sports, countries and leagues with their open game counts. The tape is every single bet on a condition, from Azuro's bets subgraph (`thegraph.onchainfeed.org`), in the YES price with the bettor's side; combo -bets are left out. There is no fee on top of the odds (`pool_margin`); a bet +bets are left out (`AzuroTrading` places and reads combos; see +[trading](trading.md#azuro)). There is no fee on top of the odds (`pool_margin`); a bet placed through the relayer pays it a flat fee per bet in the bet token, which `AzuroTrading.fetch_fee_estimate` reads. @@ -535,11 +536,11 @@ onboarded firms. | `fetch_order`, `fetch_open_orders` | yes | yes | yes | yes | yes | yes | yes | yes | yes; any order by id, in any state | yes; any order by id, in any state | yes, while the market is unsettled | yes; reads lag placing by seconds | yes; open orders are bets not yet on chain | yes | yes; an order by id is searched among the active orders and the latest thousand of the history | | `fetch_orders` (by status or time) | yes | no | no | yes, rationed to 12 requests a minute | yes, by status | partial: the venue's last 2000, filtered here | yes, open or filled | partial: per market, open or closed | no; the venue lists only resting orders | yes, by status | partial: offers on unsettled markets, by status | yes, by status and creation time, sixty days a read | yes, the wallet's bets, open or closed | yes, by status | yes, filled or cancelled, by offset or a time window | | `fetch_my_trades` | yes | yes | no; the activity feed carries no order id | yes | yes, confirmed fills | yes, from a time with `since` | yes, settled matches | yes, settled fills from the account's history | yes, the account's fills | yes, the subaccount's fills, each with its fee | partial: the matched bets on unsettled markets | yes, the account's fills, or one order's | yes, accepted bets, one fill each | partial: the account's history names neither the order nor the fee | partial: one aggregate fill per order, at its average price; each execution is on `GeminiUserStream` | -| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES; the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | -| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets: won, lost or cancelled | no | yes | +| `fetch_positions`, `fetch_balance` | yes | yes | yes | yes | yes | yes, the spot side's USDC and outcome coins | yes, USDT on chain less what orders hold | yes, USDC on Base less what orders hold | yes, the proxy wallet's USDC, free and escrowed | yes, both outcomes' holdings netted on YES; the subaccount's USD balance | yes, current matched bets netted per runner; the account's balance, exposure and free funds | partial: positions built from the live orders' matched stakes; the wallet's USD, free and locked | yes, accepted unsettled bets netted on YES, and settled ones still to claim as `redeemable` (`redeem()` claims them); the wallet's bet token on chain | yes, USD1 on BNB Chain less what open buys hold; positions on books and pools | yes, netted on YES with both inventories kept; USD available and total | +| `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | no | yes, from the settled-bets report | no | yes, settled bets and combos: won, lost or cancelled, each with whether its payout is claimed | no | yes | | `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | no | no | no | no | no | no | | `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | yes, the market's coefficient, and the maker credit as a negative fee | yes, commission on what the bet wins, makers and takers alike | no: no published rate | yes, the relayer's flat fee per bet | yes, the market's taker table at the price of the outcome bought | yes, the contract's schedule, up to the cent | -| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no | no | no: combo RFQ is a WebSocket auction for market makers, in beta | +| `rfq` | yes | no | no | no | no | no | no | no | no | no; Novig's parlay RFQ for liquidity providers is not supported yet | no | no | no; a combo needs no quote: the pool prices it from its legs (`fetch_combo_quote`, `create_combo_order`) | no | no: combo RFQ is a WebSocket auction for market makers, in beta | | `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | no | no | no | no | no; `position_calldata()` returns the venue's calldata for the wallet to send | no | Order types are a separate question. A venue holds `limit` and `market`; diff --git a/docs/trading.md b/docs/trading.md index 3b8580c..dc52d64 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -105,7 +105,7 @@ The variables each venue reads: | Novig | `NOVIG_KEY_ID` and `NOVIG_PRIVATE_KEY_PATH` (a subaccount's trading key: its id, which is also the subaccount's address, and its PKCS#8 PEM file), `NOVIG_ENV` = `production` (the default, real money) or `paper` (play money; Paper's keys and ids are its own), `NOVIG_MANAGEMENT_KEY_ID` / `NOVIG_MANAGEMENT_PRIVATE_KEY_PATH` (optional; the account's management key, for `open_subaccount`, `fund_subaccount`, `label_subaccount`, `issue_subaccount_key` and `subaccounts`) | | Matchbook | `MATCHBOOK_USERNAME`, `MATCHBOOK_PASSWORD` (betting and the account's offers, bets and balance; market data needs neither) | | ProphetX | `PROPHETX_ACCESS_KEY` and `PROPHETX_SECRET_KEY` (the Trading API: market data, streams, orders), `PROPHETX_AFFILIATE_KEY` (optional; the Market Data API, market data only), `PROPHETX_SANDBOX=1` (optional; ProphetX's sandbox, with demo funds) | -| Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance and allowance are read from and the approval is sent through; a public one when unset) | +| Azuro | `AZURO_PRIVATE_KEY` (the wallet that signs each bet and holds the bet token), `AZURO_CHAIN` (optional; `polygon`, the default, `base`, or the testnets `polygon_amoy` and `base_sepolia`), `AZURO_AFFILIATE` (optional; the affiliate address every bet names, the wallet's own when unset), `AZURO_RPC_URL` (optional; the chain's node the balance, allowance and claimable payouts are read from and the approval and claims are sent through; a public one when unset) | | Gemini | `GEMINI_API_KEY` (`account-...`), `GEMINI_API_SECRET`, `GEMINI_ACCOUNT` (optional; the subaccount a `master-...` key acts for, REST only: the WebSocket refuses master keys), `GEMINI_ENV` = `prod` (the default, real money) or `sandbox` (api.sandbox.gemini.com, with a sandbox key) | Betfair's variables (`BETFAIR_APP_KEY`, `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, @@ -723,9 +723,9 @@ Check ProphetX's terms for where it may be used before trading. ## Azuro -`AzuroTrading`, built to `@azuro-org/toolkit` and the Azuro backend API. Bet -and cash-out digests and signatures are checked against the ones the toolkit -and viem produce. +`AzuroTrading`, built to `@azuro-org/toolkit` and the Azuro backend API. Bet, +combo and cash-out digests and signatures, and the claim calldata, are checked +against the ones the toolkit and viem produce. The one credential is the wallet. Every bet is the EIP-712 message `ClientBetData` (the consent text, the affiliate, the pool's core contract, a @@ -760,13 +760,64 @@ an accepted one can be closed early where the venue offers a price: the domain `Cash Out` `1.0.0`, the offer's odds as the minimum) and sends it, and `fetch_cashout(id)` follows it. +A combo backs two or more outcomes of different games in one bet, which wins +only if every leg does. Nobody quotes it: the pool prices it from the legs' +odds by the protocol's combo rule, each leg's odds divided by 0.99 (the +feed's fee) and rounded up to two decimals, multiplied, and times 0.99 once +(1.58 and 1.58 make 2.5344, as the bets subgraph records it). A leg is a +market and a side, as an order names them (`ComboLeg(market_id, side)`, a +bare market id for its YES, or a `(market_id, side)` pair). + +```python +from synpath.trading.azuro import ComboLeg + +legs = [ComboLeg("azuro:"), ComboLeg("azuro:", Side.SELL)] +quote = await azuro.fetch_combo_quote(legs, stake=Decimal("5")) +quote.odds, quote.price, quote.max_bet, quote.problems # [] when the pool would take it +order = await azuro.create_combo_order(legs, quantity=Decimal("10"), price=quote.price * Decimal("1.02")) +await azuro.wait_for_order(order.id) +``` + +`fetch_combo_quote` reads the legs' conditions and the pool's limits on the +combo for this wallet (`/bet/calculation`), and lists in `problems` what the +venue's SDK checks before it signs one: two or more legs, each condition and +each game once, no condition closed to combos (`isExpressForbidden`), every +condition and outcome taking bets, a maximum stake above zero and the stake +within the limits. There is no maximum number of legs in the toolkit or the +SDK; the pool refuses what it will not take. `create_combo_order` is a market +order with a worst price for the whole combo: `quantity` contracts (what it +pays if every leg wins) at no more than `price` each, so minimum total odds +`1 / price` and a stake of `quantity * price`. It raises the quote's problems +before signing (`check=False` skips the quote), signs `ClientComboBetData` +(the same `ClientData`, the minimum odds, the stake, the nonce and the legs' +`ComboPart`s, under the same domain), and sends it to `/bet/orders/combo`. +The relayer's fee is the same flat fee per bet. A combo order is followed like +a single bet, and reported on its first leg's market as a `buy` at `1 /` its +total odds, with `info["combo"]` and every leg in `info["legs"]`. + `fetch_orders`, `fetch_open_orders` (bets not yet on chain), `fetch_my_trades` -(one fill per accepted bet) and `fetch_settlements` (won, lost, or cancelled -with the stake returned) read the wallet's bet orders. Positions net each -market's accepted, unsettled single bets on the YES leg. Combo bets are read -on their first leg's market and marked `info["combo"]`; this library places -single bets only. A winning bet's payout is claimed on chain from the pool, -which this library does not do. +(one fill per accepted single bet) and `fetch_settlements` (won, lost, or +cancelled with the stake returned; combos too, on their first leg's market) +read the wallet's bet orders. Each settlement says whether its payout was +claimed: `info["claimed"]` (`None` when it pays nothing), `info["claimable"]` +(what is still in the pool) and `info["claim_transaction"]`. Positions net +each market's accepted, unsettled single bets on the YES leg, and add the +settled single bets whose payout is still to be claimed, `resolved` and +`final`, with the amount in `redeemable`. + +A won or cancelled bet's payout stays in the pool until it is claimed on +chain. `fetch_redeemable_bets()` lists the wallet's bets that can be, from +the bets subgraph (won or cancelled, not cashed out, not yet claimed), each +with its on-chain id, `won` or `canceled`, and the `amount` claiming pays. +`redeem()` (also `claim_winnings()`) claims all of them, or those of +`bet_ids`, as the venue's SDK does: one `withdrawPayouts(core, betIds)` +transaction to the pool's liquidity contract for every fifty bets +(`batch=`), one paymaster `withdrawPayouts` per free bet. The wallet sends +them and pays their gas in the chain's coin; anyone may send a claim, and the +pool pays each bet's owner. Every call is estimated first, so one that would +revert (already claimed: `alreadypaid`) is refused before anything is sent. +It returns each transaction's hash, bets and amount, and the total. +`fetch_claimable(bet_id)` reads what one bet would pay now (`viewPayout`). Gnosis and Chiliz are discontinued and take no bets. Azuro's bet API answers some regions with a 503; check where Azuro may be used before betting. diff --git a/synpath/azuro.py b/synpath/azuro.py index c13caa3..72171bb 100644 --- a/synpath/azuro.py +++ b/synpath/azuro.py @@ -115,7 +115,7 @@ def subgraph_url(self) -> str: core="0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", relayer="0x8dA05c0021e6b35865FDC959c54dCeF3A4AbBa9d", lp="0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", cashout="0x4a2BB4211cCF9b9eA6eF01D0a61448154ED19095", token="0xc2132d05d31c914a87c6611c10748aeb04b58e8f", symbol="USDT", decimals=6, - rpc_url="https://polygon-rpc.com", + rpc_url="https://polygon-bor-rpc.publicnode.com", ), "base": Chain( "base", 8453, "BaseWETH", "base", @@ -690,6 +690,20 @@ def fetch_condition_states(self, condition_ids: list[str]) -> list[dict[str, Any rows = (data or {}).get("conditions") or [] if isinstance(data, dict) else [] return [r for r in rows if isinstance(r, dict) and r.get("conditionId")] + def fetch_conditions(self, condition_ids: list[str]) -> dict[str, dict[str, Any]]: + """Many conditions by id, each as its game's condition listing has it + (`/market-manager/conditions-by-game-ids`): state, outcomes and odds, + the game, and `isExpressForbidden`. A condition whose id names no game + is read from the condition batch, without those last two.""" + wanted = list(dict.fromkeys(str(c) for c in condition_ids)) + games = list(dict.fromkeys(g for g in (game_of_condition(c) for c in wanted) if g)) + found = {str(row["conditionId"]): row for rows in self._conditions(games).values() for row in rows + if str(row["conditionId"]) in wanted} + missing = [c for c in wanted if c not in found] + if missing: + found.update({str(row["conditionId"]): row for row in self.fetch_condition_states(missing)}) + return {c: found[c] for c in wanted if c in found} + def condition_outcomes(self, condition_ids: list[str]) -> dict[str, list[str]]: """Each condition's outcome ids, lowest first: what decides its markets' ids and which outcome is YES.""" diff --git a/synpath/trading/azuro.py b/synpath/trading/azuro.py index a9c532d..14a1543 100644 --- a/synpath/trading/azuro.py +++ b/synpath/trading/azuro.py @@ -29,21 +29,33 @@ but where the venue offers it a bet can be cashed out before it settles (`fetch_cashout_quote`, `cash_out`). +**A combo** backs two or more outcomes of different games in one bet, which +wins only if every leg does. It is priced by the pool, not quoted by anyone: +its odds are the legs' odds by the protocol's combo rule (`azuro_signing.combo_odds`). +`fetch_combo_quote` reads the legs' odds, states and the pool's limits on the +combo and says what stops it; `create_combo_order` signs it with a worst price +for the whole combo (minimum total odds `1 / price`) and sends it to +`/bet/orders/combo`. It is then followed like a single bet. + **Positions** net a market's accepted, unsettled single bets on the YES leg: -the contracts on the YES outcome less those on the other. A winning bet's -payout is claimed on chain from the pool; this library does not send that +the contracts on the YES outcome less those on the other. Settled single bets +whose payout is still to be claimed are positions too, `resolved` with the +amount waiting in `redeemable`. A won or cancelled bet's payout stays in the +pool until it is claimed on chain: `fetch_redeemable_bets` lists them and +`redeem` sends the claims from the wallet, `CLAIM_BATCH` bets of one pool a transaction. """ from __future__ import annotations import asyncio import time +from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal from typing import Any from .. import ids -from ..azuro import Azuro, Chain, chain_of, game_of_condition, parse_market_id +from ..azuro import Azuro, Chain, chain_of, game_of_condition, odds_value, outcome_odds, parse_market_id from ..base import AsyncHttpClient, Capability from ..errors import BadRequest, ExchangeError, MarketNotFound, NotSupported from ..types import Page @@ -84,6 +96,32 @@ ORDERS_PAGE = 100 MAX_ORDER_PAGES = 50 +MIN_COMBO_LEGS = 2 +"""A combo is two or more legs. The toolkit and the SDK set no upper bound; +the pool refuses what it will not take (accepted combos of 33 legs exist).""" + +CLAIM_BATCH = 50 +"""Bets claimed in one `withdrawPayouts` transaction. The contract takes any +number; this bounds each transaction's gas.""" + +SUBGRAPH_PAGE = 100 +MAX_SUBGRAPH_PAGES = 20 + +REDEEMABLE_QUERY = """query Redeemable($first: Int!, $skip: Int!, $where: V3_Bet_filter!) { + v3Bets(first: $first, skip: $skip, where: $where, orderBy: createdBlockNumber, orderDirection: desc, + subgraphError: allow) { + id tokenId: betId actor type status result amount rawAmount odds settledOdds + potentialPayout rawPotentialPayout payout rawPayout + isRedeemed isRedeemable isCashedOut redeemedTxHash + freebetId isFreebetAmountReturnable paymasterContractAddress + createdAt: createdBlockTimestamp resolvedAt: resolvedBlockTimestamp txHash: createdTxHash + core { address liquidityPool { address tokenDecimals } } + selections { odds result outcome { outcomeId condition { conditionId gameId status } } } + } +}""" +"""The SDK's `Bet` fragment, trimmed, on the bets subgraph; with the toolkit's +`Unredeemed` filter (`isRedeemable`, not cashed out) it lists what can be claimed.""" + def D(value: Any) -> Decimal: if value in (None, ""): @@ -127,6 +165,37 @@ def _leg(row: dict[str, Any]) -> dict[str, Any]: return legs[0] if legs and isinstance(legs[0], dict) else {} +def _legs(row: dict[str, Any]) -> list[dict[str, Any]]: + return [leg for leg in row.get("conditions") or [] if isinstance(leg, dict)] + + +def is_combo(row: dict[str, Any]) -> bool: + return row.get("betType") == "COMBO" + + +def combo_legs_of(row: dict[str, Any], outcomes: dict[str, list[str]]) -> list[dict[str, Any]]: + """A combo's legs: each one's market and side there, with the leg's odds + and result as the venue records them.""" + out = [] + for leg in _legs(row): + condition_id, outcome_id = str(leg.get("conditionId") or ""), str(leg.get("outcomeId") or "") + market_id, side = market_of(condition_id, outcome_id, outcomes.get(condition_id)) + out.append({"market_id": market_id, "side": side, "condition_id": condition_id, "outcome_id": outcome_id, + "odds": D(leg.get("price")) if leg.get("price") not in (None, "") else None, + "result": leg.get("result")}) + return out + + +def claim_of_row(row: dict[str, Any], payout: Decimal) -> dict[str, Any]: + """Whether a settled bet's payout is claimed, and what is left to claim: + a won or cancelled bet, not cashed out, pays from the pool once claimed.""" + meta = row.get("meta") if isinstance(row.get("meta"), dict) else {} + claimed = bool(row.get("redeemedAt") or meta.get("isRedeemed")) + payable = row.get("result") in ("Won", "Canceled") and not meta.get("isCashedOut") and payout > 0 + return {"claimed": claimed if payable else None, "claimable": payout if payable and not claimed else Decimal("0"), + "claim_transaction": meta.get("redeemedTxHash")} + + def contracts_of_row(row: dict[str, Any]) -> Decimal: """What the bet pays if it wins: its stake times its odds.""" leg = _leg(row) @@ -135,10 +204,12 @@ def contracts_of_row(row: dict[str, Any]) -> Decimal: return D(row.get("amount")) * D(row.get("odds")) -def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None) -> Order: +def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None, + legs_outcomes: dict[str, list[str]] | None = None) -> Order: """One bet order (`GET /bet/orders/{id}`, `GET /bet/orders/bettor/...`), - as an order on the YES leg. A combo is reported on its first leg's market, - marked `info["combo"]`.""" + as an order on the YES leg. A combo is reported on its first leg's market + as a buy at `1 / ` its total odds, marked `info["combo"]`, with every + leg's market and side in `info["legs"]` (`legs_outcomes` names them).""" leg = _leg(row) market_id, side = market_of(str(leg.get("conditionId") or ""), str(leg.get("outcomeId") or ""), outcomes) state = str(row.get("state") or "") @@ -146,6 +217,11 @@ def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Ac odds = D(row.get("odds") or leg.get("price")) quantity = contracts_of_row(row) filled = quantity if state in FILLED_STATES else Decimal("0") + extra: dict[str, Any] = {"combo": is_combo(row)} + if is_combo(row): + side = Side.BUY + extra["legs"] = combo_legs_of(row, {**(legs_outcomes or {}), + str(leg.get("conditionId") or ""): outcomes or []}) price = yes_price(odds, side) return Order( id=str(row.get("id") or ""), @@ -164,7 +240,7 @@ def order_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Ac cost=D(row.get("amount")), created_at=iso_ms(row.get("createdAt")), updated_at=iso_ms(row.get("updatedAt")), - info={**row, "combo": row.get("betType") == "COMBO"}, + info={**row, **extra}, ) @@ -189,12 +265,16 @@ def fill_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Acc ) -def settlement_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None) -> Settlement | None: - """A settled single bet: won, lost, or cancelled (the stake returned).""" +def settlement_of_row(row: dict[str, Any], *, outcomes: list[str] | None, account: Account | None = None, + legs_outcomes: dict[str, list[str]] | None = None) -> Settlement | None: + """A settled bet: won, lost, or cancelled (the stake returned), with + `info["claimed"]` (whether a payout was claimed; `None` when there is + none) and `info["claimable"]` (what is still in the pool to claim). A + combo is reported on its first leg's market, marked `info["combo"]`.""" result = row.get("result") - if row.get("betType") == "COMBO" or result in (None, ""): + if result in (None, ""): return None - order = order_of_row(row, outcomes=outcomes, account=account) + order = order_of_row(row, outcomes=outcomes, account=account, legs_outcomes=legs_outcomes) cost = D(row.get("amount")) payout = D(row.get("payout")) if row.get("payout") is not None else (order.quantity if result == "Won" else cost if result == "Canceled" else Decimal("0")) @@ -204,7 +284,8 @@ def settlement_of_row(row: dict[str, Any], *, outcomes: list[str] | None, accoun result=str(result).lower(), won=result == "Won" if result != "Canceled" else None, quantity=order.quantity, cost=cost, payout=payout, pnl=payout - cost, timestamp=iso_ms(row.get("settledAt")) or iso_ms(row.get("updatedAt")), - info=row, + info={**row, "combo": order.info["combo"], **({"legs": order.info["legs"]} if "legs" in order.info else {}), + **claim_of_row(row, payout)}, ) @@ -233,6 +314,117 @@ def positions_of(rows: list[dict[str, Any]], *, outcomes: dict[str, list[str]], return out +# --------------------------------------------------------------------------- +# Redeemable bets +# --------------------------------------------------------------------------- + +def freebet_share(payout: int, amount: int, returnable: bool | None) -> int: + """The bettor's part of a free bet's payout (the toolkit's + `calcFreebetBettorShare`): nothing unless it beat the stake, then the + payout less the stake, or all of it when the stake is not returnable.""" + if payout <= amount: + return 0 + return payout if returnable is False else payout - amount + + +def redeemable_of_bet(bet: dict[str, Any]) -> dict[str, Any] | None: + """One bet from the bets subgraph as a claim: its on-chain id, whether it + `won` or was `canceled`, the `amount` claiming pays the wallet, and the + contracts to claim from. `None` for a bet with nothing to claim.""" + won = bet.get("result") == "Won" + canceled = bet.get("status") == "Canceled" + if not bet.get("isRedeemable") or bet.get("isCashedOut") or not (won or canceled): + return None + core = bet.get("core") or {} + pool = core.get("liquidityPool") or {} + decimals = int(pool.get("tokenDecimals") or 0) + stake = int(D(bet.get("rawAmount"))) + raw = int(D(bet["rawPayout"])) if bet.get("rawPayout") not in (None, "") else \ + int(D(bet.get("rawPotentialPayout"))) if won else stake + freebet = str(bet.get("freebetId") or "") or None + if freebet: + raw = freebet_share(raw, stake, bet.get("isFreebetAmountReturnable")) + return { + "bet_id": str(bet.get("tokenId") or bet.get("betId")), + "combo": bet.get("type") == "Express", + "result": "won" if won else "canceled", + "amount": sig.token_amount(raw, decimals), + "raw_amount": raw, + "stake": sig.token_amount(stake, decimals), + "core": str(core.get("address") or ""), + "lp": str(pool.get("address") or ""), + "freebet_id": freebet, + "paymaster": bet.get("paymasterContractAddress"), + "legs": [(str(((sel.get("outcome") or {}).get("condition") or {}).get("conditionId") or ""), + str((sel.get("outcome") or {}).get("outcomeId") or "")) for sel in bet.get("selections") or []], + "info": bet, + } + + +def claim_calls(bets: list[dict[str, Any]], *, chain: Chain, batch: int = CLAIM_BATCH) -> list[dict[str, Any]]: + """The transactions that claim these bets (`redeemable_of_bet`), as the + SDK's `useRedeemBet` builds them: one `withdrawPayouts(core, betIds)` to + the pool's liquidity contract per `batch` bets of one core; one + `withdrawPayout(core, betId)` each on a legacy pool; one paymaster + `withdrawPayouts([freeBetId])` per free bet. Each is `{"to", "data", + "bet_ids", "raw_amount"}`.""" + if batch < 1: + raise InvalidOrder(f"{VENUE}: a claim batch holds at least one bet") + calls: list[dict[str, Any]] = [] + pools: dict[tuple[str, str], list[dict[str, Any]]] = {} + for bet in bets: + if bet.get("freebet_id"): + if not bet.get("paymaster"): + raise InvalidOrder(f"{VENUE}: free bet {bet['bet_id']} names no paymaster to claim from") + calls.append({"to": bet["paymaster"], "data": sig.paymaster_withdraw_payouts_calldata([bet["freebet_id"]]), + "bet_ids": [bet["bet_id"]], "raw_amount": bet["raw_amount"]}) + continue + pools.setdefault((bet["lp"].lower(), bet["core"].lower()), []).append(bet) + for (lp, core), group in pools.items(): + if lp != chain.lp.lower(): + calls += [{"to": b["lp"], "data": sig.withdraw_payout_calldata(b["core"], b["bet_id"]), + "bet_ids": [b["bet_id"]], "raw_amount": b["raw_amount"]} for b in group] + continue + for start in range(0, len(group), batch): + chunk = group[start:start + batch] + calls.append({"to": chain.lp, "data": sig.withdraw_payouts_calldata(core, [b["bet_id"] for b in chunk]), + "bet_ids": [b["bet_id"] for b in chunk], "raw_amount": sum(b["raw_amount"] for b in chunk)}) + return calls + + +def claimable_positions_of(bets: list[dict[str, Any]], *, outcomes: dict[str, list[str]], decimals: int, + account: Account | None = None) -> list[Position]: + """Settled single bets waiting to be claimed, per market: `resolved`, + `won` (`None` when cancelled), and the amount waiting in `redeemable`. + Combos have no one market and are left to `fetch_redeemable_bets`.""" + nets: dict[str, dict[str, Any]] = {} + for bet in bets: + if bet["combo"] or not bet["legs"]: + continue + condition_id, outcome_id = bet["legs"][0] + market_id, side = market_of(condition_id, outcome_id, outcomes.get(condition_id)) + entry = nets.setdefault(market_id, {"yes": Decimal("0"), "no": Decimal("0"), "cost": Decimal("0"), + "amount": Decimal("0"), "won": set(), "bets": []}) + info = bet["info"] + entry["yes" if side == Side.BUY else "no"] += sig.token_amount(int(D(info.get("rawPotentialPayout"))), decimals) + entry["cost"] += bet["stake"] + entry["amount"] += bet["amount"] + entry["won"].add(bet["result"] == "won") + entry["bets"].append(bet["bet_id"]) + out = [] + for market_id, entry in nets.items(): + net = entry["yes"] - entry["no"] + out.append(Position( + venue=VENUE, account=account, market_id=market_id, + side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT, + contracts=abs(net), inventory_yes=entry["yes"], inventory_no=entry["no"], margin=entry["cost"], + resolved=True, final=True, won=True if entry["won"] == {True} else None, + payout=entry["amount"], redeemable=entry["amount"], + info={"bets": "settled single bets not yet claimed", "bet_ids": entry["bets"]}, + )) + return out + + def error_of(exc: ExchangeError) -> ExchangeError: body = exc.body if isinstance(exc.body, dict) else {} text = str(body.get("errorMessage") or body.get("message") or body.get("error") or exc) @@ -267,24 +459,28 @@ def check_request(request: OrderRequest) -> None: raise InvalidOrder(f"{VENUE}: bets are not netted at the venue; there is no reduce-only bet") -def selection_of(request: OrderRequest, outcomes: list[str]) -> tuple[str, str, Decimal]: - """(condition id, outcome id, price paid for the outcome) for a request.""" - condition_id, outcome_id = parse_market_id(ids.native(VENUE, request.market_id)) - price = D(request.price) +def outcome_of(market_id: str, side: Side, outcomes: list[str]) -> tuple[str, str]: + """(condition id, outcome id) a side of a market backs.""" + condition_id, outcome_id = parse_market_id(ids.native(VENUE, market_id)) if outcome_id is not None: if len(outcomes) == 2: raise InvalidOrder(f"{VENUE}: {condition_id} has two outcomes and is one market, {VENUE}:{condition_id}") if outcomes and outcome_id not in outcomes: raise InvalidOrder(f"{VENUE}: condition {condition_id} has no outcome {outcome_id}") - if request.side == Side.SELL: + if side == Side.SELL: raise InvalidOrder(f"{VENUE}: one outcome of a larger condition has only YES to buy; the pool sells no NO") - return condition_id, outcome_id, price + return condition_id, outcome_id if len(outcomes) != 2: raise InvalidOrder(f"{VENUE}: condition {condition_id} has {len(outcomes)} outcomes; name one: " f"{', '.join(f'{VENUE}:{condition_id}-{o}' for o in outcomes)}") - if request.side == Side.BUY: - return condition_id, outcomes[0], price - return condition_id, outcomes[1], Decimal(1) - price + return condition_id, outcomes[0] if side == Side.BUY else outcomes[1] + + +def selection_of(request: OrderRequest, outcomes: list[str]) -> tuple[str, str, Decimal]: + """(condition id, outcome id, price paid for the outcome) for a request.""" + condition_id, outcome_id = outcome_of(request.market_id, request.side, outcomes) + price = D(request.price) + return condition_id, outcome_id, price if request.side == Side.BUY else Decimal(1) - price def build_bet( @@ -311,6 +507,148 @@ def relayer_fee_of(info: Any) -> int: return int(D(row["relayerFeeAmount"])) +# --------------------------------------------------------------------------- +# Combos +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ComboLeg: + """One leg of a combo: a market and the side backed there, as an order + names them (`buy` its YES outcome, `sell` the other outcome of a + two-outcome market).""" + + market_id: str + side: Side = Side.BUY + + +def legs_of(legs: list[ComboLeg | str | tuple[str, Side | str]]) -> list[ComboLeg]: + """Legs given as `ComboLeg`s, market ids (backing YES) or `(market_id, side)` pairs.""" + out = [] + for leg in legs: + if isinstance(leg, ComboLeg): + out.append(leg) + elif isinstance(leg, str): + out.append(ComboLeg(leg)) + else: + market_id, side = leg + out.append(ComboLeg(market_id, Side(side))) + return out + + +@dataclass +class ComboQuote: + """What the pool would take a combo at now: its total `odds` by the + protocol's combo rule and their `price` (`1 / odds`, per contract), the + pool's limits on it in the bet token, and every reason it would be + refused (`problems`; none when `eligible`).""" + + legs: list[dict[str, Any]] + odds: Decimal | None + price: Decimal | None + currency: str + min_bet: Decimal | None = None + max_bet: Decimal | None = None + max_payout: Decimal | None = None + problems: list[str] = field(default_factory=list) + info: dict[str, Any] = field(default_factory=dict) + + @property + def eligible(self) -> bool: + return not self.problems + + +def _decimal(value: Any) -> Decimal | None: + return D(value) if value not in (None, "") else None + + +def combo_quote_of( + legs: list[ComboLeg], selections: list[tuple[str, str]], conditions: dict[str, dict[str, Any]], + limits: dict[str, Any] | None, *, currency: str, stake: Decimal | None = None, +) -> ComboQuote: + """A combo's quote from its legs' conditions (`Azuro.fetch_conditions`) + and the pool's limits on it (`/bet/calculation`), with the checks the + venue's SDK makes before it signs one: two or more legs, each condition + once and each game once, no condition closed to combos + (`isExpressForbidden`), every condition and outcome taking bets, a + maximum stake above zero, and the stake within the limits.""" + problems: list[str] = [] + out_legs: list[dict[str, Any]] = [] + if len(selections) < MIN_COMBO_LEGS: + problems.append(f"a combo needs at least {MIN_COMBO_LEGS} legs") + seen_conditions: set[str] = set() + seen_games: dict[str, str] = {} + leg_odds: list[Decimal] = [] + for leg, (condition_id, outcome_id) in zip(legs, selections): + condition = conditions.get(condition_id) + game_id = game_of_condition(condition_id) or str(((condition or {}).get("game") or {}).get("gameId") or "") or None + entry: dict[str, Any] = {"market_id": ids.qualify(VENUE, ids.native(VENUE, leg.market_id)), "side": leg.side, + "condition_id": condition_id, "outcome_id": outcome_id, "game_id": game_id, + "odds": None} + out_legs.append(entry) + if condition_id in seen_conditions: + problems.append(f"condition {condition_id} is in the combo twice") + seen_conditions.add(condition_id) + if game_id is not None: + if game_id in seen_games and seen_games[game_id] != condition_id: + problems.append(f"game {game_id} is in the combo twice; legs must be on different games") + seen_games.setdefault(game_id, condition_id) + if condition is None: + problems.append(f"no condition {condition_id}") + continue + if condition.get("isExpressForbidden"): + problems.append(f"condition {condition_id} is not offered in combos") + if str(condition.get("state") or "") != "Active": + problems.append(f"condition {condition_id} is {condition.get('state') or 'not taking bets'}") + outcome = next((o for o in condition.get("outcomes") or [] if str(o.get("outcomeId")) == outcome_id), None) + if outcome is None: + problems.append(f"condition {condition_id} has no outcome {outcome_id}") + continue + if str(outcome.get("state") or "Active") != "Active" or outcome.get("hidden") is True: + problems.append(f"outcome {outcome_id} of {condition_id} is not taking bets") + odds = odds_value(outcome_odds(outcome)) + entry["odds"] = odds + if odds is None: + problems.append(f"outcome {outcome_id} of {condition_id} has no odds") + else: + leg_odds.append(odds) + total = sig.combo_odds(leg_odds) if leg_odds and len(leg_odds) == len(selections) else None + if total is not None and total <= 1: + problems.append(f"the combo's odds {total} are too low") + raw = limits or {} + min_bet, max_bet, max_payout = _decimal(raw.get("minBet")), _decimal(raw.get("maxBet")), _decimal(raw.get("maxPayout")) + if limits is not None: + if max_bet is not None and max_bet <= 0: + problems.append("the pool takes nothing on this combo now") + elif stake is not None and max_bet is not None and stake > max_bet: + problems.append(f"a stake of {stake} is above the pool's maximum of {max_bet}") + if stake is not None and min_bet is not None and stake < min_bet: + problems.append(f"a stake of {stake} is below the pool's minimum of {min_bet}") + return ComboQuote( + legs=out_legs, odds=total, price=(Decimal(1) / total) if total else None, currency=currency, + min_bet=min_bet, max_bet=max_bet, max_payout=max_payout, problems=problems, + info={"limits": raw, "conditions": conditions}, + ) + + +def build_combo( + selections: list[tuple[str, str]], *, chain: Chain, signer: WalletSigner, quantity: Decimal, price: Decimal, + relayer_fee: int, affiliate: str | None = None, nonce: int, now: float | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """`(body, combo)` for a combo, signed, before anything is sent: `quantity` + contracts (what it pays if every leg wins) at a worst `price` per contract + for the whole combo, which is minimum total odds `1 / price`.""" + if len(selections) < MIN_COMBO_LEGS: + raise InvalidOrder(f"{VENUE}: a combo needs at least {MIN_COMBO_LEGS} legs") + price = D(price) + data = sig.client_data(chain, affiliate=affiliate or signer.address, + expires_at=int(time.time() if now is None else now) + DEADLINE_S, relayer_fee=relayer_fee) + combo = sig.combo_bet(legs=selections, min_odds=sig.min_odds(price), + amount=sig.stake(D(quantity), price, chain.decimals), nonce=nonce) + body = sig.wire_combo(chain, bettor=signer.address, data=data, combo=combo, + signature=sig.sign_combo(signer, data, combo)) + return body, combo + + # --------------------------------------------------------------------------- # Adapter # --------------------------------------------------------------------------- @@ -417,7 +755,8 @@ async def outcomes_of(self, condition_ids: list[str]) -> dict[str, list[str]]: return {c: self._outcomes.get(c, []) for c in condition_ids} async def _orders_from(self, rows: list[dict[str, Any]]) -> dict[str, list[str]]: - return await self.outcomes_of([str(_leg(r).get("conditionId") or "") for r in rows]) + """The outcomes of every leg's condition in these rows.""" + return await self.outcomes_of([str(leg.get("conditionId") or "") for r in rows for leg in _legs(r)]) def _next_nonce(self) -> int: """Milliseconds, as the venue's SDK uses, never repeated in this process.""" @@ -496,7 +835,8 @@ async def fetch_order(self, order_id: str) -> Order: if not isinstance(row, dict) or not row.get("id"): raise OrderNotFound(f"{VENUE}: no bet order {order_id}") outcomes = await self._orders_from([row]) - return order_of_row(row, outcomes=outcomes.get(str(_leg(row).get("conditionId") or "")), account=self.account) + return order_of_row(row, outcomes=outcomes.get(str(_leg(row).get("conditionId") or "")), account=self.account, + legs_outcomes=outcomes) async def _bettor_rows(self, *, states: list[str] | None = None, result: list[str] | None = None, offset: int = 0, limit: int | None = None) -> list[dict[str, Any]]: @@ -534,8 +874,8 @@ async def fetch_orders( offset, size = int(cursor or 0), min(limit or 50, ORDERS_PAGE) rows = await self._bettor_rows(states=states, offset=offset, limit=size) outcomes = await self._orders_from(rows) - orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account) - for r in rows] + orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account, + legs_outcomes=outcomes) for r in rows] if status == "closed": orders = [o for o in orders if o.is_terminal] if market_id: @@ -549,8 +889,8 @@ async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order """Bets signed and not yet on chain: nothing rests in a pool.""" rows = await self._bettor_rows(states=list(PENDING_STATES)) outcomes = await self._orders_from(rows) - orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account) - for r in rows] + orders = [order_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), account=self.account, + legs_outcomes=outcomes) for r in rows] if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) orders = [o for o in orders if o.market_id == wanted] @@ -581,14 +921,17 @@ async def fetch_settlements( self, *, market_id: str | None = None, since: int | None = None, limit: int | None = None, cursor: str | None = None, ) -> Page[Settlement]: - """This wallet's settled single bets: won, lost or cancelled.""" + """This wallet's settled bets, singles and combos: won, lost or + cancelled, each with whether its payout is claimed (`info["claimed"]`, + `info["claimable"]`).""" if cursor is not None and not cursor.isdigit(): raise BadRequest(f"{VENUE}: {cursor!r} is not a cursor this API issued") offset, size = int(cursor or 0), min(limit or 50, ORDERS_PAGE) rows = await self._bettor_rows(result=["Won", "Lost", "Canceled"], offset=offset, limit=size) outcomes = await self._orders_from(rows) out = [s for s in (settlement_of_row(r, outcomes=outcomes.get(str(_leg(r).get("conditionId") or "")), - account=self.account) for r in rows) if s is not None] + account=self.account, legs_outcomes=outcomes) for r in rows) + if s is not None] if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) out = [s for s in out if s.market_id == wanted] @@ -621,37 +964,70 @@ async def approve(self, amount: Decimal | None = None, *, wait: bool = True, tim default, as the venue's SDK does, or `amount` tokens. An on-chain transaction from the wallet, which pays its gas in the chain's coin.""" value = MAX_UINT256 if amount is None else int(D(amount) * (Decimal(10) ** self.chain.decimals)) - data = sig.approve_calldata(self.chain.relayer, value) + call = {"to": self.chain.token, "data": sig.approve_calldata(self.chain.relayer, value)} + estimate = await self._estimate(call, what="approval") + [sent] = await self._send_calls([(call, estimate)]) + if not wait: + return {"transaction_hash": sent} + return {"transaction_hash": sent, "receipt": await self._receipt(sent, timeout_s=timeout_s, what="approval")} + + # -- wallet transactions -------------------------------------------------- + + async def _estimate(self, call: dict[str, Any], *, what: str) -> int: + """The gas a call from the wallet takes; a call that would revert is + refused here, named by the contract's error where it is one of the + pool's, before anything is sent.""" + try: + return int(await self._rpc("eth_estimateGas", [{"from": self.address, "to": call["to"], + "data": call["data"]}]), 16) + except ExchangeError as exc: + error = (exc.body or {}).get("error") if isinstance(exc.body, dict) else None + data = str((error or {}).get("data") or "") if isinstance(error, dict) else "" + name = sig.PAYOUT_ERRORS.get(data[:10].lower()) + reason = name or (str(error.get("message")) if isinstance(error, dict) else "reverted") + raise OrderRejected(f"{VENUE}: {what} would revert: {reason}", reason=(name or "reverted").lower(), + info={"call": call, "error": error}) from None + + async def _send_calls(self, calls: list[tuple[dict[str, Any], int]]) -> list[str]: + """Sign and send calls from the wallet, one transaction each with + consecutive nonces; their hashes. The wallet pays the gas.""" nonce = int(await self._rpc("eth_getTransactionCount", [self.address, "pending"]), 16) gas_price = int(await self._rpc("eth_gasPrice", []), 16) priority_fee = int(await self._rpc("eth_maxPriorityFeePerGas", []), 16) - estimate = int(await self._rpc("eth_estimateGas", [{"from": self.address, "to": self.chain.token, "data": data}]), 16) - tx = { - "chainId": self.chain.chain_id, "nonce": nonce, "to": self.chain.token, "value": 0, "data": data, - "gas": int(estimate * 1.2), "maxFeePerGas": gas_price * 2 + priority_fee, - "maxPriorityFeePerGas": priority_fee, "type": 2, - } - tx_hash = await self._rpc("eth_sendRawTransaction", [self.signer.sign_transaction(tx)]) - if not wait: - return {"transaction_hash": tx_hash} + hashes = [] + for offset, (call, estimate) in enumerate(calls): + tx = { + "chainId": self.chain.chain_id, "nonce": nonce + offset, "to": call["to"], "value": 0, + "data": call["data"], "gas": int(estimate * 1.2), "maxFeePerGas": gas_price * 2 + priority_fee, + "maxPriorityFeePerGas": priority_fee, "type": 2, + } + hashes.append(await self._rpc("eth_sendRawTransaction", [self.signer.sign_transaction(tx)])) + return hashes + + async def _receipt(self, tx_hash: str, *, timeout_s: float, what: str, poll_s: float = 2.0) -> dict[str, Any]: loop = asyncio.get_running_loop() stop = loop.time() + timeout_s while True: receipt = await self._rpc("eth_getTransactionReceipt", [tx_hash]) if receipt: if int(receipt.get("status", "0x0"), 16) != 1: - raise OrderRejected(f"{VENUE}: approval {tx_hash} reverted", reason="reverted", info=receipt) - return {"transaction_hash": tx_hash, "receipt": receipt} + raise OrderRejected(f"{VENUE}: {what} {tx_hash} reverted", reason="reverted", info=receipt) + return receipt if loop.time() >= stop: - raise ExchangeError(f"{VENUE}: approval {tx_hash} unmined after {timeout_s}s; it may yet confirm") - await asyncio.sleep(2.0) + raise ExchangeError(f"{VENUE}: {what} {tx_hash} unmined after {timeout_s}s; it may yet confirm") + await asyncio.sleep(poll_s) async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]: - """Accepted, unsettled single bets netted per market on the YES leg.""" + """Accepted, unsettled single bets netted per market on the YES leg, + and settled single bets whose payout waits to be claimed (`resolved`, + the amount in `redeemable`).""" rows = await self._bettor_rows(states=["Accepted", "CancelFailed"]) rows = [r for r in rows if r.get("result") in (None, "")] - outcomes = await self._orders_from(rows) + claims = await self.fetch_redeemable_bets() + outcomes = await self.outcomes_of([str(leg.get("conditionId") or "") for r in rows for leg in _legs(r)] + + [b["legs"][0][0] for b in claims if not b["combo"] and b["legs"]]) positions = positions_of(rows, outcomes=outcomes, account=self.account) + positions += claimable_positions_of(claims, outcomes=outcomes, decimals=self.chain.decimals, account=self.account) if market_id: wanted = ids.qualify(VENUE, ids.native(VENUE, market_id)) positions = [p for p in positions if p.market_id == wanted] @@ -671,6 +1047,166 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency=self.chain.symbol, info={"charged": "per bet, to the relayer, for the transaction"}, ) + # -- combos --------------------------------------------------------------- + + async def _combo_selections(self, legs: list[ComboLeg]) -> list[tuple[str, str]]: + conditions = [parse_market_id(ids.native(VENUE, leg.market_id))[0] for leg in legs] + outcomes = await self.outcomes_of(conditions) + for condition_id in conditions: + if not outcomes[condition_id]: + raise MarketNotFound(f"{VENUE}: no condition {condition_id}") + return [outcome_of(leg.market_id, leg.side, outcomes[c]) for leg, c in zip(legs, conditions)] + + async def fetch_combo_limits(self, selections: list[tuple[str, str]]) -> dict[str, Any]: + """The pool's limits on these legs together (`/bet/calculation`), for + this wallet: `minBet`, `maxBet` and `maxPayout` in the bet token.""" + raw = await self._call("POST", "/bet/calculation", body={ + "bets": [{"conditionId": c, "outcomeId": int(o)} for c, o in selections], + "environment": self.chain.environment, "wallet": self.address, + }) + raw = (raw or {}).get("response", raw) if isinstance(raw, dict) else {} + return raw if isinstance(raw, dict) else {} + + async def fetch_combo_quote( + self, legs: list[ComboLeg | str | tuple[str, Side | str]], *, stake: Decimal | None = None, + ) -> ComboQuote: + """What the pool would take a combo of these legs at now: the total + odds by the protocol's combo rule from the legs' current odds, their + price, the pool's limits on the combo, and why it would be refused + (`problems`). `stake` (in the bet token) is checked against the + limits. Nothing is signed or sent.""" + legs = legs_of(legs) + selections = await self._combo_selections(legs) + conditions = await asyncio.to_thread(self.catalog.fetch_conditions, [c for c, _ in selections]) + limits: dict[str, Any] | None = None + refused = None + try: + limits = await self.fetch_combo_limits(selections) + except (BadRequest, MarketNotFound) as exc: + refused = str(exc) + quote = combo_quote_of(legs, selections, conditions, limits, currency=self.chain.symbol, + stake=D(stake) if stake is not None else None) + if refused: + quote.problems.append(f"the pool's limits were refused: {refused}") + return quote + + async def create_combo_order( + self, legs: list[ComboLeg | str | tuple[str, Side | str]], *, quantity: Decimal, price: Decimal, + client_order_id: str | None = None, check: bool = True, + ) -> Order: + """Sign one combo and hand it to the relayer (`/bet/orders/combo`): + `quantity` contracts, what it pays if every leg wins, at a worst + `price` per contract for the whole combo, so its minimum total odds + are `1 / price` and its stake `quantity * price`. A market order with + a worst price, like a single bet: the pool takes it whole at its odds + when it reaches the chain, or refuses it. With `check` (the default) + the quote's problems are raised before anything is signed. Returns the + order pending, on its first leg's market, marked `info["combo"]`; + follow it with `fetch_order` or `wait_for_order`.""" + legs = legs_of(legs) + price, quantity = D(price), D(quantity) + selections = await self._combo_selections(legs) + if check: + quote = await self.fetch_combo_quote(legs, stake=quantity * price) + if quote.problems: + raise InvalidOrder(f"{VENUE}: combo refused before signing: {'; '.join(quote.problems)}") + body, combo = build_combo(selections, chain=self.chain, signer=self.signer, quantity=quantity, price=price, + relayer_fee=await self.relayer_fee(), affiliate=self.credentials.affiliate, + nonce=self._next_nonce()) + try: + answer = await self._call("POST", "/bet/orders/combo", body=body, kind="write") or {} + except (BadRequest, MarketNotFound) as exc: + raise error_of(exc) from None + state = str(answer.get("state") or "") + if state != "Created" or not answer.get("id"): + raise OrderRejected(f"{VENUE}: combo not created: {answer.get('errorMessage') or state or answer}", + reason=str(answer.get("error") or state or "rejected").lower(), info=answer) + outcomes = await self.outcomes_of([c for c, _ in selections]) + named = [dict(zip(("market_id", "side"), market_of(c, o, outcomes[c])), condition_id=c, outcome_id=o) + for c, o in selections] + paid = Decimal(sig.ODDS_SCALE) / Decimal(int(combo["minOdds"])) + cost = sig.token_amount(combo["amount"], self.chain.decimals) + return Order( + id=str(answer["id"]), client_order_id=client_order_id, venue=VENUE, account=self.account, + market_id=named[0]["market_id"], side=Side.BUY, type=OrderType.MARKET, time_in_force=TimeInForce.FOK, + status=OrderStatus.PENDING, price=paid, quantity=(cost / paid).quantize(Decimal("0.000001")), + filled=Decimal("0"), cost=cost, created_at=int(time.time() * 1000), + info={"answer": answer, "combo": True, "bet": combo, "legs": named}, + ) + + # -- claims --------------------------------------------------------------- + + async def _redeemable_page(self, skip: int) -> list[dict[str, Any]]: + await self.limiter.acquire(cost=1, kind="read", priority=Priority.NORMAL) # type: ignore[arg-type] + raw = await self.http.request("POST", self.chain.subgraph_url, json={ + "query": REDEEMABLE_QUERY, + "variables": {"first": SUBGRAPH_PAGE, "skip": skip, + "where": {"actor": self.address.lower(), "isRedeemable": True, "isCashedOut": False}}, + }) + if not isinstance(raw, dict) or raw.get("errors") or not isinstance(raw.get("data"), dict): + raise ExchangeError(f"{VENUE}: bets subgraph: {raw}", body=raw) + return [b for b in raw["data"].get("v3Bets") or [] if isinstance(b, dict)] + + async def fetch_redeemable_bets(self) -> list[dict[str, Any]]: + """This wallet's bets whose payout is still in the pool, from the bets + subgraph: won, or cancelled (the stake returned), not cashed out, not + yet claimed. Each is `{"bet_id", "combo", "result" ("won" or + "canceled"), "amount" (what claiming pays, in the bet token), "stake", + "core", "lp", "freebet_id", "paymaster", "legs", "info"}`.""" + bets: list[dict[str, Any]] = [] + for page in range(MAX_SUBGRAPH_PAGES): + rows = await self._redeemable_page(page * SUBGRAPH_PAGE) + bets += [b for b in (redeemable_of_bet(r) for r in rows) if b is not None] + if len(rows) < SUBGRAPH_PAGE: + break + return bets + + async def fetch_claimable(self, bet_id: str | int, *, core: str | None = None) -> Decimal: + """What claiming one bet would pay now, read on chain from the pool + (`viewPayout`): zero once claimed, for a lost bet, or before it settles.""" + raw = await self._rpc("eth_call", [{"to": self.chain.lp, "data": sig.view_payout_calldata( + core or self.chain.core, bet_id)}, "latest"]) + return sig.token_amount(int(raw, 16), self.chain.decimals) + + async def redeem( + self, bet_ids: list[str | int] | None = None, *, wait: bool = True, timeout_s: float = 120.0, + batch: int = CLAIM_BATCH, + ) -> dict[str, Any]: + """Claim won and cancelled bets' payouts into the wallet: every bet + `fetch_redeemable_bets` lists, or those of `bet_ids`. One + `withdrawPayouts` transaction to the pool per `batch` bets, one per + free bet (the paymaster's), sent from the wallet, which pays the gas in + the chain's coin. Each call is first estimated, so one that would + revert (already claimed: `alreadypaid`) is refused before anything is + sent. Returns `{"transactions": [{"transaction_hash", "to", + "bet_ids", "amount", "receipt"}], "bet_ids", "amount", "currency"}`; + nothing to claim sends nothing.""" + bets = await self.fetch_redeemable_bets() + if bet_ids is not None: + wanted = [str(b) for b in bet_ids] + known = {b["bet_id"] for b in bets} + missing = [b for b in wanted if b not in known] + if missing: + raise InvalidOrder(f"{VENUE}: nothing to claim on bet {', '.join(missing)}: not won or cancelled, " + "cashed out, already claimed, or not this wallet's") + bets = [b for b in bets if b["bet_id"] in wanted] + calls = claim_calls(bets, chain=self.chain, batch=batch) + if not calls: + return {"transactions": [], "bet_ids": [], "amount": Decimal("0"), "currency": self.chain.symbol} + estimates = [await self._estimate(call, what=f"claim of bet {', '.join(call['bet_ids'])}") for call in calls] + hashes = await self._send_calls(list(zip(calls, estimates))) + out = [] + for call, tx_hash in zip(calls, hashes): + entry: dict[str, Any] = {"transaction_hash": tx_hash, "to": call["to"], "bet_ids": call["bet_ids"], + "amount": sig.token_amount(call["raw_amount"], self.chain.decimals)} + if wait: + entry["receipt"] = await self._receipt(tx_hash, timeout_s=timeout_s, what="claim") + out.append(entry) + return {"transactions": out, "bet_ids": [b for call in calls for b in call["bet_ids"]], + "amount": sum((e["amount"] for e in out), Decimal("0")), "currency": self.chain.symbol} + + claim_winnings = redeem + # -- cash-out ------------------------------------------------------------- async def fetch_cashout_quote(self, bet_id: str | int) -> dict[str, Any] | None: @@ -719,4 +1255,7 @@ async def close(self) -> None: self.catalog.close() -__all__ = ["AzuroTrading", "order_of_row", "fill_of_row", "settlement_of_row", "positions_of", "build_bet"] +__all__ = [ + "AzuroTrading", "ComboLeg", "ComboQuote", "order_of_row", "fill_of_row", "settlement_of_row", "positions_of", + "claimable_positions_of", "build_bet", "build_combo", "combo_quote_of", "redeemable_of_bet", "claim_calls", +] diff --git a/synpath/trading/azuro_signing.py b/synpath/trading/azuro_signing.py index 0ccd8b5..ff207fe 100644 --- a/synpath/trading/azuro_signing.py +++ b/synpath/trading/azuro_signing.py @@ -1,8 +1,11 @@ -"""Azuro bet and cash-out signing, and the amounts in them, with no network in sight. +"""Azuro bet, combo and cash-out signing, the amounts in them, and the pool's +payout calls, with no network in sight. Built to `@azuro-org/toolkit` 7.2.0 (`getBetTypedData`, `createBet`, -`getCashoutTypedData`, `createCashout`), and checked against digests and -signatures that toolkit and viem produce (`tests/samples/azuro_signing_vectors.json`). +`getComboBetTypedData`, `createComboBet`, `calcMinOdds`, `getCashoutTypedData`, +`createCashout`, the LP and PayMaster ABIs) and `@azuro-org/sdk` 8.2.0 +(`useRedeemBet`), and checked against digests, signatures and calldata that +toolkit and viem produce (`tests/samples/azuro_signing_vectors.json`). **A bet** is the EIP-712 message `ClientBetData`: the `ClientData` (a consent text, the affiliate, the pool's core contract, an expiry, the chain id, the @@ -17,6 +20,17 @@ names its worst acceptable odds; the YES price given is turned into them as `1 / price`, rounded up, so a bet never pays more per contract than asked. +**A combo** is the EIP-712 message `ClientComboBetData` under the same +domain: the same `ClientData`, one minimum for the total odds, one stake, one +nonce, and two or more `ComboPart`s (a condition and an outcome each). Its +total odds are the protocol's combo rule: each leg's odds with the feed's +fee taken off (divided by 0.99) and rounded up to two decimals, multiplied +together, and the fee taken once from the product (times 0.99). + +**A payout** is claimed on chain from the pool's liquidity contract: +`withdrawPayouts(core, betIds)` for any number of one core's bets, paid to +each bet's owner; a free bet's from the paymaster, `withdrawPayouts(freeBetIds)`. + **A cash-out** is `CashOutOrder` (a consent text, the chain id, one `CashOutItem` -- the bet's id, the core contract, the minimum odds -- and an expiry) under the domain `Cash Out` version `1.0.0`, the chain's id and the @@ -69,6 +83,20 @@ {"name": "nonce", "type": "uint256"}, ], } +COMBO_BET_TYPES: dict[str, list[dict[str, str]]] = { + "ClientComboBetData": [ + {"name": "clientData", "type": "ClientData"}, + {"name": "minOdds", "type": "uint64"}, + {"name": "amount", "type": "uint128"}, + {"name": "nonce", "type": "uint256"}, + {"name": "bets", "type": "ComboPart[]"}, + ], + "ClientData": CLIENT_DATA_FIELDS, + "ComboPart": [ + {"name": "conditionId", "type": "uint256"}, + {"name": "outcomeId", "type": "uint128"}, + ], +} CASHOUT_TYPES: dict[str, list[dict[str, str]]] = { "CashOutItem": [ {"name": "betId", "type": "uint256"}, @@ -121,6 +149,29 @@ def token_amount(raw: int | str, decimals: int) -> Decimal: return Decimal(int(raw)) / (Decimal(10) ** decimals) +COMBO_FEE_MODIFIER = Decimal("0.99") +"""The toolkit's `ODDS_COMBO_FEE_MODIFIER`: the feed's fee on every outcome's odds.""" + +CENT = Decimal("0.01") + + +def combo_odds(leg_odds: list[Decimal | str | float]) -> Decimal: + """A combo's total odds by the protocol's rule: each leg's odds over + 0.99, rounded up to two decimals, multiplied, times 0.99, exactly. The + bets subgraph records accepted combos at these odds (1.58 and 1.58 make + 2.5344), cut to the protocol's twelve decimals. The toolkit's + `calcMinOdds` cuts the same figure to two.""" + if not leg_odds: + raise InvalidOrder("azuro: a combo needs legs") + total = Decimal(1) + for odds in leg_odds: + value = Decimal(str(odds)) + if value <= 1: + raise InvalidOrder(f"azuro: odds {value} take no bets") + total *= (value / COMBO_FEE_MODIFIER).quantize(CENT, rounding=ROUND_CEILING) + return (total * COMBO_FEE_MODIFIER).quantize(Decimal(1) / ODDS_SCALE, rounding=ROUND_DOWN) + + # --------------------------------------------------------------------------- # EIP-712 # --------------------------------------------------------------------------- @@ -209,14 +260,19 @@ def bet_domain(data: dict[str, Any]) -> dict[str, Any]: "verifyingContract": data["core"]} +def client_message(data: dict[str, Any]) -> dict[str, Any]: + """`ClientData` as it is signed: numbers as integers.""" + return { + **{k: data[k] for k in ("attention", "affiliate", "core", "isFeeSponsored", "isBetSponsored", + "isSponsoredBetReturnable")}, + "expiresAt": int(data["expiresAt"]), "chainId": int(data["chainId"]), + "relayerFeeAmount": int(data["relayerFeeAmount"]), + } + + def bet_message(data: dict[str, Any], bet: dict[str, Any]) -> dict[str, Any]: return { - "clientData": { - **{k: data[k] for k in ("attention", "affiliate", "core", "isFeeSponsored", "isBetSponsored", - "isSponsoredBetReturnable")}, - "expiresAt": int(data["expiresAt"]), "chainId": int(data["chainId"]), - "relayerFeeAmount": int(data["relayerFeeAmount"]), - }, + "clientData": client_message(data), "bets": [{k: int(bet[k]) for k in ("conditionId", "outcomeId", "minOdds", "amount", "nonce")}], } @@ -254,6 +310,61 @@ def wire_bet(chain: Chain, *, bettor: str, data: dict[str, Any], bet: dict[str, } +# --------------------------------------------------------------------------- +# Combos +# --------------------------------------------------------------------------- + +def combo_bet(*, legs: list[tuple[str, str | int]], min_odds: int, amount: int, nonce: int) -> dict[str, Any]: + """The combo's own fields, as decimal strings: the minimum total odds, + the stake, the nonce, and its `(condition, outcome)` legs in order.""" + return {"minOdds": str(int(min_odds)), "amount": str(int(amount)), "nonce": str(int(nonce)), + "bets": [{"conditionId": str(c), "outcomeId": str(o)} for c, o in legs]} + + +def combo_message(data: dict[str, Any], combo: dict[str, Any]) -> dict[str, Any]: + return { + "clientData": client_message(data), + "minOdds": int(combo["minOdds"]), "amount": int(combo["amount"]), "nonce": int(combo["nonce"]), + "bets": [{"conditionId": int(b["conditionId"]), "outcomeId": int(b["outcomeId"])} for b in combo["bets"]], + } + + +def combo_typed_data(data: dict[str, Any], combo: dict[str, Any]) -> dict[str, Any]: + """The combo as EIP-712 typed data, for wallets that sign that.""" + return { + "primaryType": "ClientComboBetData", + "types": {"EIP712Domain": DOMAIN_FIELDS, **COMBO_BET_TYPES}, + "domain": bet_domain(data), + "message": combo_message(data, combo), + } + + +def combo_digest(data: dict[str, Any], combo: dict[str, Any]) -> bytes: + return digest(bet_domain(data), "ClientComboBetData", COMBO_BET_TYPES, combo_message(data, combo)) + + +def sign_combo(signer: WalletSigner, data: dict[str, Any], combo: dict[str, Any]) -> str: + return "0x" + bytes(signer.sign_digest(combo_digest(data, combo))).hex() + + +def wire_combo(chain: Chain, *, bettor: str, data: dict[str, Any], combo: dict[str, Any], + signature: str) -> dict[str, Any]: + """The signed combo as `POST /bet/orders/combo` takes it.""" + return { + "environment": chain.environment, + "bettor": bettor.lower(), + "betOwner": bettor.lower(), + "clientBetData": { + "clientData": data, + "amount": combo["amount"], + "minOdds": combo["minOdds"], + "nonce": combo["nonce"], + "bets": [{"conditionId": b["conditionId"], "outcomeId": int(b["outcomeId"])} for b in combo["bets"]], + }, + "bettorSignature": signature, + } + + # --------------------------------------------------------------------------- # Cash-out # --------------------------------------------------------------------------- @@ -314,3 +425,40 @@ def allowance_calldata(owner: str, spender: str) -> str: def balance_calldata(owner: str) -> str: abi_encode, _, _, keccak, _ = _eth() return "0x" + (keccak(text="balanceOf(address)")[:4] + abi_encode(["address"], [owner])).hex() + + +def _call(signature: str, types: list[str], args: list[Any]) -> str: + abi_encode, _, _, keccak, _ = _eth() + return "0x" + (keccak(text=signature)[:4] + abi_encode(types, args)).hex() + + +def withdraw_payouts_calldata(core: str, bet_ids: list[int | str]) -> str: + """`LP.withdrawPayouts(core, betIds)`: every one of one core's bets in one + transaction, each paid to its owner; a lost bet in the batch pays 0.""" + _, _, _, _, to_checksum_address = _eth() + return _call("withdrawPayouts(address,uint256[])", ["address", "uint256[]"], + [to_checksum_address(core), [int(b) for b in bet_ids]]) + + +def withdraw_payout_calldata(core: str, bet_id: int | str) -> str: + """`LP.withdrawPayout(core, betId)`: one bet, as a legacy pool takes it.""" + _, _, _, _, to_checksum_address = _eth() + return _call("withdrawPayout(address,uint256)", ["address", "uint256"], [to_checksum_address(core), int(bet_id)]) + + +def view_payout_calldata(core: str, bet_id: int | str) -> str: + """`LP.viewPayout(core, betId)`: what claiming the bet would pay now.""" + _, _, _, _, to_checksum_address = _eth() + return _call("viewPayout(address,uint256)", ["address", "uint256"], [to_checksum_address(core), int(bet_id)]) + + +def paymaster_withdraw_payouts_calldata(freebet_ids: list[int | str]) -> str: + """`PayMaster.withdrawPayouts(freeBetIds)`: a free bet's payout.""" + return _call("withdrawPayouts(uint256[])", ["uint256[]"], [[int(f) for f in freebet_ids]]) + + +PAYOUT_ERRORS = { + "0xd70a0e30": "AlreadyPaid", "0xf67321e5": "BetNotExists", "0xeafd377b": "CobmoBetNotResolved", + "0x3ef97a7b": "ComboBetResolvedPartially", "0xacb88053": "ConditionNotFinished", +} +"""The core's revert selectors a claim can meet, by name (the ABI's spelling).""" diff --git a/tests/samples/azuro_redeemable_bets.json b/tests/samples/azuro_redeemable_bets.json new file mode 100644 index 0000000..8d06776 --- /dev/null +++ b/tests/samples/azuro_redeemable_bets.json @@ -0,0 +1,923 @@ +{ + "data": { + "v3Bets": [ + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_467993", + "nonce": "1792453197498648", + "tokenId": "467993", + "actor": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "amount": "2", + "rawAmount": "2000000", + "status": "Resolved", + "potentialPayout": "5.84", + "rawPotentialPayout": "5840000", + "payout": "5.84", + "rawPayout": "5840000", + "result": "Won", + "odds": "2.92", + "settledOdds": "2.92", + "createdAt": "1792453202", + "resolvedAt": "1792887459", + "txHash": "0x5eebe42c514d4446b56c946d4145bcb326c073b4bac695d6de5f40f9b05fa028", + "redeemedTxHash": null, + "redeemedAt": null, + "affiliate": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "isRedeemed": false, + "isRedeemable": true, + "isCashedOut": false, + "type": "Ordinar", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "2.92", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "Draw", + "result": "Won", + "condition": { + "conditionId": "600610060000000000901243060000000000000131378013", + "title": "Match Result", + "status": "Resolved", + "gameId": "1006000000000090124306", + "wonOutcomeIds": [ + "3" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_467650", + "nonce": "1792436638282604", + "tokenId": "467650", + "actor": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "amount": "1", + "rawAmount": "1000000", + "status": "Resolved", + "potentialPayout": "1.53", + "rawPotentialPayout": "1530000", + "payout": "1.53", + "rawPayout": "1530000", + "result": "Won", + "odds": "1.53", + "settledOdds": "1.53", + "createdAt": "1792436642", + "resolvedAt": "1792886403", + "txHash": "0x9f8d41cd2c4828a2c7cbd97435dd19a2c36479d7d66cabf5d5f270a988f4f75b", + "redeemedTxHash": null, + "redeemedAt": null, + "affiliate": "0x58a48986d2aa0ab20339c4ddab32e352df38749f", + "isRedeemed": false, + "isRedeemable": true, + "isCashedOut": false, + "type": "Ordinar", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.53", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "CSYD Madryn", + "result": "Won", + "condition": { + "conditionId": "600610060000000000942518730000000000001216194534", + "title": "Match Result", + "status": "Resolved", + "gameId": "1006000000000094251873", + "wonOutcomeIds": [ + "3" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472005", + "nonce": "1792686315014", + "tokenId": "472005", + "actor": "0xed425fd3187907d9a2b73e8bc2b8eecf3b2b5ac2", + "amount": "6.9", + "rawAmount": "6900000", + "status": "Resolved", + "potentialPayout": "17.48736", + "rawPotentialPayout": "17487360", + "payout": "17.48736", + "rawPayout": "17487360", + "result": "Won", + "odds": "2.5344", + "settledOdds": "2.5344", + "createdAt": "1792686321", + "resolvedAt": "1792693640", + "txHash": "0xa530adc77f42c72256cfd7ed01cf9b14e83428d9f9be5c622e39015c21ec4d0a", + "redeemedTxHash": "0x95a16fc4d29589b13e82e88f81104a320192a5caf37eeca7cd278fd65250b43b", + "redeemedAt": "1792745766", + "affiliate": "0x82875517c2b4bd534de4b2c0d21bff5f40b25da6", + "isRedeemed": true, + "isRedeemable": false, + "isCashedOut": false, + "type": "Express", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.58", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": null, + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514730000000000000737294024", + "title": "Total Goals Over/Under", + "status": "Resolved", + "gameId": "1006000000000091651473", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.58", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Over 2.5", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515400000000000000736020810", + "title": "Total Goals Over/Under", + "status": "Resolved", + "gameId": "1006000000000091651540", + "wonOutcomeIds": [ + "2" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472056", + "nonce": "1792688164836", + "tokenId": "472056", + "actor": "0x8d693762eab04504e77f4ed8ec5251e48fc360d4", + "amount": "10", + "rawAmount": "10000000", + "status": "Canceled", + "potentialPayout": "19.3", + "rawPotentialPayout": "19300000", + "payout": "10", + "rawPayout": "10000000", + "result": null, + "odds": "1.93", + "settledOdds": "1", + "createdAt": "1792688184", + "resolvedAt": "1792688325", + "txHash": "0x6a577d19219ff7ecae53a1ca2dcfdb21134a1268ce9856fad4b32c6f768f0164", + "redeemedTxHash": "0x034542780bd4527a8e118e6d469498201efb55f930c496a326540de0585fcd28", + "redeemedAt": "1792688513", + "affiliate": "0x82875517c2b4bd534de4b2c0d21bff5f40b25da6", + "isRedeemed": true, + "isRedeemable": false, + "isCashedOut": false, + "type": "Ordinar", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.93", + "result": "Canceled", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Over 5.5", + "result": "Won", + "condition": { + "conditionId": "600610060000000000312449060000000000001815430603", + "title": "1st Period - Total Goals Over/Under", + "status": "Resolved", + "gameId": "1006000000000031244906", + "wonOutcomeIds": [ + "1" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + }, + { + "id": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7_472057", + "nonce": "1792688227188", + "tokenId": "472057", + "actor": "0x066210a533c93eae126c82d4a7b14309dd2eed0f", + "amount": "4.03", + "rawAmount": "4030000", + "status": "Resolved", + "potentialPayout": "84.698904", + "rawPotentialPayout": "84698904", + "payout": "0", + "rawPayout": "0", + "result": "Lost", + "odds": "21.017097888654", + "settledOdds": "21.017097888654", + "createdAt": "1792688246", + "resolvedAt": "1792694669", + "txHash": "0xc5e0611b789d2b0a75f39fa580b86698a6953d2735c37166a9eea08b64e4be5e", + "redeemedTxHash": "0x47deafd7db37897a74332d3de0715eee38cb3fe00182f94bc486a6d965aee5a8", + "redeemedAt": "1792699209", + "affiliate": "0x82875517c2b4bd534de4b2c0d21bff5f40b25da6", + "isRedeemed": true, + "isRedeemable": false, + "isCashedOut": false, + "type": "Express", + "core": { + "address": "0xf9548be470a4e130c90cea8b179fcd66d2972ac7", + "liquidityPool": { + "address": "0x0fa7fb5407ea971694652e6e16c12a52625de1b8", + "tokenDecimals": 6 + } + }, + "selections": [ + { + "odds": "1.01", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Mjallby AIF (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483460000000000001815498996", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648346", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "2", + "title": "PFC CSKA-Sofia (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483670000000000000737295297", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648367", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.04", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "2", + "title": "FC Copenhagen (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483680000000000000737293675", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648368", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.1", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "2", + "title": "FK Jablonec (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483780000000000001653198308", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648378", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.06", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "FC Lugano (+2)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483920000000000001158202482", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648392", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.14", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "Inter Club de Escaldes (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916483930000000000001815241020", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648393", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.08", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Heart of Midlothian FC (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916484160000000000000985481078", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648416", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.06", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "KF Egnatia (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916484170000000000000947866001", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648417", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.1", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "Atalanta (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916485180000000000000764314586", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648518", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.1", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "AS Monaco (+2)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916485300000000000000738450974", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648530", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.15", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "Sint-Truidense VV (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916485350000000000000738453218", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648535", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.05", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "FC Thun (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916487820000000000001158201838", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648782", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.11", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "Kuopio PS (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916488040000000000001158200802", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648804", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.09", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "Lincoln Red Imps FC (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916488150000000000001166475176", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648815", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "KAA Gent (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916488590000000000000738452342", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091648859", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.04", + "result": "Lost", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "FC Ararat-Armenia (+4)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916514730000000000000737294086", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651473", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.12", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": null, + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514740000000000000736019218", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651474", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.11", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "AC Milan (+3)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916514750000000000000733827079", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651475", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.13", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Celtic FC (+2)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514770000000000000734889585", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651477", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Crystal Palace FC (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514780000000000000793589865", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651478", + "wonOutcomeIds": [ + "2" + ] + } + } + }, + { + "odds": "1.12", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "PFC Levski Sofia (+3)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916514810000000000000734890387", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651481", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.05", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Jagiellonia Bialystok (+3)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515100000000000000736022324", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651510", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.03", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "NEC Nijmegen (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515360000000000001279442782", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651536", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.03", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "3", + "title": "Stade Rennais FC (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515400000000000001815328026", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651540", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.05", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "OFI Crete (+5)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515430000000000000796841115", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651543", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.05", + "result": "Lost", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Hapoel Beer Sheva (+4)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515540000000000000737295934", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651554", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "Lech Poznan (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515640000000000000737675871", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651564", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.03", + "result": "Won", + "conditionKind": "Live", + "outcome": { + "outcomeId": "1", + "title": "SC Uniao Torreense (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515660000000000000737294563", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651566", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.07", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "1", + "title": "NK Celje (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515680000000000001302681191", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651568", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.09", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Lillestrom SK (+4)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515700000000000000733827215", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651570", + "wonOutcomeIds": [ + "1" + ] + } + } + }, + { + "odds": "1.09", + "result": "Lost", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "Olympique Lyonnais (+3)", + "result": "Lost", + "condition": { + "conditionId": "600610060000000000916515720000000000000733828856", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651572", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.13", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "3", + "title": "AC Omonia Nicosia (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515730000000000000734226777", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651573", + "wonOutcomeIds": [ + "3" + ] + } + } + }, + { + "odds": "1.05", + "result": "Won", + "conditionKind": "Prematch", + "outcome": { + "outcomeId": "2", + "title": "AC Sparta Praha (+4)", + "result": "Won", + "condition": { + "conditionId": "600610060000000000916515740000000000000793571877", + "title": "Handicap 3-Way", + "status": "Resolved", + "gameId": "1006000000000091651574", + "wonOutcomeIds": [ + "2" + ] + } + } + } + ], + "freebetId": null, + "isFreebetAmountReturnable": null, + "paymasterContractAddress": null, + "cashout": null + } + ] + } +} \ No newline at end of file diff --git a/tests/samples/azuro_signing_vectors.json b/tests/samples/azuro_signing_vectors.json index 6b7b8c6..2b3eb68 100644 --- a/tests/samples/azuro_signing_vectors.json +++ b/tests/samples/azuro_signing_vectors.json @@ -249,5 +249,235 @@ "socket": "wss://streams.onchainfeed.org/v1/streams", "bets": "https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-chiliz-v3" } - } + }, + "combo_source": "@azuro-org/toolkit 7.2.0 getComboBetTypedData, calcMinOdds, lpAbi/paymasterAbi; viem hashTypedData, signTypedData, encodeFunctionData", + "combos": [ + { + "name": "polygon combo of two", + "chainId": 137, + "clientData": { + "attention": "By signing this transaction, I agree to place a bet for an event on Azuro Protocol", + "affiliate": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "core": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", + "expiresAt": 1792780300, + "chainId": 137, + "relayerFeeAmount": "12345", + "isBetSponsored": false, + "isFeeSponsored": false, + "isSponsoredBetReturnable": false + }, + "amount": "5000000", + "minOdds": "3270000000000", + "nonce": "1792780000001", + "bets": [ + { + "conditionId": "600610060000000000943019940000000000001802657415", + "outcomeId": "1" + }, + { + "conditionId": "600610060000000000028666020000000000001624778125", + "outcomeId": "2" + } + ], + "domain": { + "name": "Live Betting", + "version": "1.0.0", + "chainId": 137, + "verifyingContract": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7" + }, + "digest": "0x81c1b2e5926ac33dfbb99b7eff5888800a492e0883e1bd6e0f0da4b075a2c76c", + "signature": "0x72efde622b42e27aaec0502ca40f483ac97e2b0c1925a9e44ace33f6ab4739765a13f876a60a1b366c6ecad328954b47446230ea11c9f5385f91586f36f950aa1c" + }, + { + "name": "base combo of three", + "chainId": 8453, + "clientData": { + "attention": "By signing this transaction, I agree to place a bet for an event on Azuro Protocol", + "affiliate": "0x1111111111111111111111111111111111111111", + "core": "0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d", + "expiresAt": 1792780300, + "chainId": 8453, + "relayerFeeAmount": "12345", + "isBetSponsored": false, + "isFeeSponsored": false, + "isSponsoredBetReturnable": false + }, + "amount": "1000000000000000", + "minOdds": "5123456789012", + "nonce": "42", + "bets": [ + { + "conditionId": "600610060000000000943019940000000000001406934671", + "outcomeId": "3" + }, + { + "conditionId": "600610060000000000028772180000000000002040246119", + "outcomeId": "1" + }, + { + "conditionId": "600610060000000000916514730000000000000737294024", + "outcomeId": "2" + } + ], + "domain": { + "name": "Live Betting", + "version": "1.0.0", + "chainId": 8453, + "verifyingContract": "0xF40cF1dD7d16C098cff5F8B5650A8FaEf1F4640d" + }, + "digest": "0x6f73db62c4a5e5dd266ced51cccbacf46154e771f50029918399f0023b244e2d", + "signature": "0xdc89a348d0a4f3cdd714d83dfbb0c7db13995a63f4e40c1eaae5ef8a5452b4517a44b138cd389bbddb78cf8d0712e9e1fa11bc90d3aa15bdb123537622f15a0d1b" + } + ], + "calls": { + "lp": "0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", + "core": "0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7", + "paymaster": "0xeD5760fC2d2f6d363d73e576173e5e8Ca6A877e1", + "withdrawPayouts": "0xe8e3e87b000000000000000000000000f9548be470a4e130c90cea8b179fcd66d2972ac70000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000733f800000000000000000000000000000000000000000000000000000000000733f700000000000000000000000000000000000000000000000000000000000733f200000000000000000000000000000000000000000000000000000000000733f100000000000000000000000000000000000000000000000000000000000733f0", + "withdrawPayout": "0x161d0770000000000000000000000000f9548be470a4e130c90cea8b179fcd66d2972ac70000000000000000000000000000000000000000000000000000000000072419", + "viewPayout": "0x25aa820b000000000000000000000000f9548be470a4e130c90cea8b179fcd66d2972ac70000000000000000000000000000000000000000000000000000000000072419", + "paymasterWithdrawPayouts": "0x51eab807000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000002329", + "chains": { + "56": { + "lp": "0xa2db60D3DcecEc80d5D30c680a7b1e2018cD909f", + "paymaster": "0x3445f0C78Ce1141247c1BcB653FA28b65cD5FA89", + "azuroBet": "0x6Fb6a3A0D8fE1214AFFa18A7c3428078C7dd843d" + }, + "97": { + "lp": "0x431A0993d29eEb0fF7e3FE351A303eF72195431a", + "paymaster": "0x725ec0A9eC9dC993A86d0eFD7fD78929d226AbE7", + "azuroBet": "0xF7815889e5d0635A31eca34390b25d8D2cEeD902" + }, + "100": { + "lp": "0xeb7cDA87D00d677A6Dc73EB569723b0fA51D97E7", + "paymaster": "0x3Ebf1a2005f1a87862d61022a5BAC661994dD94e", + "azuroBet": "0xd7cd9342A14962C2dCB14569CB6fcA2759999089" + }, + "137": { + "lp": "0x0FA7FB5407eA971694652E6E16C12A52625DE1b8", + "paymaster": "0xeD5760fC2d2f6d363d73e576173e5e8Ca6A877e1", + "azuroBet": "0x7A1c3FEf712753374C4DCe34254B96faF2B7265B" + }, + "8453": { + "lp": "0x1eD7368bc515E928A4007cEa61FB8a6F8863Af87", + "paymaster": "0x4a2BB4211cCF9b9eA6eF01D0a61448154ED19095", + "azuroBet": "0xF328404Dbc8c997d12dC55a1A179AF7F8cb7df90" + }, + "80002": { + "lp": "0x0a75395Ff15d9557424b632cEBCac448D66F9779", + "paymaster": "0x2d155962b708c931Fc695F674B415065E78D9F04", + "azuroBet": "0x4B75c071dFA5d537979E8b0615Bb97B6337dbFef" + }, + "84532": { + "lp": "0x4fE86EddCC22dc992b7994878169201Ec7ea50AB", + "paymaster": "0xC2577cdcF684C9756f2C81efdA001CbEFEf3Dd92", + "azuroBet": "0xD031C4BDd4b7c30e3725132109786B8E158A4c07" + }, + "88882": { + "lp": "0x431A0993d29eEb0fF7e3FE351A303eF72195431a", + "paymaster": "0x3df16FB8Dc28F63565AF2815E04a3368360FFd23", + "azuroBet": "0xF7815889e5d0635A31eca34390b25d8D2cEeD902" + }, + "88888": { + "lp": "0xEf6b12580301b04CD2551182C88623524B6e47b8", + "paymaster": "0xB3AE923721e8f3345654eFffC09f6F52afC21F4c", + "azuroBet": "0x3777B4F27F4B36f14454dbB1f79278bcba694B52" + } + }, + "onchain_withdrawPayouts_tx": "0x034542780bd4527a8e118e6d469498201efb55f930c496a326540de0585fcd28" + }, + "combo_odds": [ + { + "odds": [ + 1.58, + 1.58 + ], + "slippage": 0, + "minOdds": "2.530000000000" + }, + { + "odds": [ + 1.58, + 1.58 + ], + "slippage": 5, + "minOdds": "2.450000000000" + }, + { + "odds": [ + 1.51, + 1.55 + ], + "slippage": 0, + "minOdds": "2.370000000000" + }, + { + "odds": [ + 1.51, + 1.55 + ], + "slippage": 5, + "minOdds": "2.300000000000" + }, + { + "odds": [ + 2.04, + 2.6 + ], + "slippage": 0, + "minOdds": "5.380000000000" + }, + { + "odds": [ + 2.04, + 2.6 + ], + "slippage": 5, + "minOdds": "5.160000000000" + }, + { + "odds": [ + 1.36, + 2.77, + 6.83 + ], + "slippage": 0, + "minOdds": "26.390000000000" + }, + { + "odds": [ + 1.36, + 2.77, + 6.83 + ], + "slippage": 5, + "minOdds": "25.120000000000" + }, + { + "odds": [ + 1.01, + 1.07, + 1.04, + 1.1, + 1.06, + 1.14, + 1.08 + ], + "slippage": 0, + "minOdds": "1.810000000000" + }, + { + "odds": [ + 1.01, + 1.07, + 1.04, + 1.1, + 1.06, + 1.14, + 1.08 + ], + "slippage": 5, + "minOdds": "1.760000000000" + } + ] } diff --git a/tests/test_azuro.py b/tests/test_azuro.py index 310799e..67f0b4f 100644 --- a/tests/test_azuro.py +++ b/tests/test_azuro.py @@ -290,6 +290,17 @@ def test_a_larger_condition_needs_an_outcome(self): venue.fetch_market(f"azuro:{CORNERS['conditionId']}-1") assert venue.fetch_market(f"azuro:{THREE_WAY['conditionId']}-3").yes.label == "Trefelin FC (-1)" + def test_conditions_by_id_carry_their_game_and_combo_flag(self): + venue = venue_with(FEED) + found = venue.fetch_conditions([CORNERS["conditionId"], SETTLED_ID, "123", CORNERS["conditionId"]]) + assert list(found) == [CORNERS["conditionId"], SETTLED_ID] + assert found[CORNERS["conditionId"]]["isExpressForbidden"] is False + assert found[CORNERS["conditionId"]]["game"]["gameId"] == GAME["gameId"] + [(_, path, body), (_, batch_path, batch)] = venue.http.calls + assert path == "/market-manager/conditions-by-game-ids" + assert sorted(body["gameIds"]) == sorted([GAME["gameId"], FINISHED["gameId"]]) + assert batch_path == "/market-manager/condition-batch" and batch["conditionIds"] == ["123"] + def test_an_unknown_market(self): venue = venue_with({**FEED, "POST /market-manager/conditions-by-game-ids": {"conditions": []}, "POST /market-manager/condition-batch": {"conditions": []}}) diff --git a/tests/test_azuro_trading.py b/tests/test_azuro_trading.py index e29847e..865c99d 100644 --- a/tests/test_azuro_trading.py +++ b/tests/test_azuro_trading.py @@ -13,12 +13,16 @@ import pytest from eth_account import Account as EthAccount from eth_account.messages import _hash_eip191_message, encode_typed_data +from eth_account.typed_transactions import TypedTransaction +from hexbytes import HexBytes import synpath from synpath.azuro import CHAINS from synpath.trading import azuro_signing as sig from synpath.trading.azuro import ( - AzuroTrading, build_bet, check_request, fill_of_row, order_of_row, positions_of, relayer_fee_of, settlement_of_row, + AzuroTrading, ComboLeg, build_bet, build_combo, check_request, claim_calls, claimable_positions_of, + combo_quote_of, fill_of_row, freebet_share, order_of_row, positions_of, redeemable_of_bet, relayer_fee_of, + settlement_of_row, ) from synpath.trading.credentials import AzuroCredentials, CredentialsMissing, load_azuro, load_credentials from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected @@ -33,7 +37,8 @@ POLYGON = CHAINS["polygon"] CORNERS = "600610060000000000943019940000000000001802657415" THREE_WAY = "600610060000000000943019940000000000001406934671" -OUTCOMES = {CORNERS: ["1", "2"], THREE_WAY: ["1", "2", "3"]} +OTHER_GAME = "600610060000000000028666020000000000001624778125" +OUTCOMES = {CORNERS: ["1", "2"], THREE_WAY: ["1", "2", "3"], OTHER_GAME: ["1", "2"]} def request(**changes) -> OrderRequest: @@ -253,12 +258,17 @@ def sent(self, key: str) -> list[httpx.Request]: class Catalog: - def __init__(self): + def __init__(self, conditions=None, outcomes=None): self.asked: list[list[str]] = [] + self.conditions = conditions or {} + self.outcomes = {**OUTCOMES, **(outcomes or {})} def condition_outcomes(self, condition_ids): self.asked.append(list(condition_ids)) - return {c: OUTCOMES[c] for c in condition_ids if c in OUTCOMES} + return {c: self.outcomes[c] for c in condition_ids if c in self.outcomes} + + def fetch_conditions(self, condition_ids): + return {c: self.conditions[c] for c in condition_ids if c in self.conditions} def close(self): pass @@ -269,9 +279,13 @@ def close(self): BETTOR = f"GET /api/v1/public/bet/orders/bettor/{OWNER.address}" -def adapter(venue: Venue, **creds) -> AzuroTrading: +SUBGRAPH = "POST /subgraphs/name/azuro-protocol/azuro-api-polygon-v3" + + +def adapter(venue: Venue, *, catalog: Catalog | None = None, **creds) -> AzuroTrading: credentials = AzuroCredentials(private_key=KEY, rpc_url="https://rpc.test/", **creds) - return AzuroTrading(credentials, catalog=Catalog(), client=httpx.AsyncClient(transport=httpx.MockTransport(venue))) + return AzuroTrading(credentials, catalog=catalog or Catalog(), + client=httpx.AsyncClient(transport=httpx.MockTransport(venue))) def run(coro): @@ -362,7 +376,7 @@ async def go(): def test_the_bettors_lists(self): pending = row(id="p", state="Sent") settled = row(id="s", state="Settled", result="Won", payout=10.2, settledAt="2026-10-23T12:00:00.000Z") - venue = Venue(**{BETTOR: {"orders": [row(), pending, settled]}}) + venue = Venue(**{BETTOR: {"orders": [row(), pending, settled]}, SUBGRAPH: {"data": {"v3Bets": []}}}) async def go(): async with adapter(venue) as azuro: @@ -455,6 +469,442 @@ async def go(): assert body["clientBetData"]["clientData"]["core"] == CHAINS["base"].core +# --------------------------------------------------------------------------- +# Combos and claims +# --------------------------------------------------------------------------- + +BETS = load("azuro_redeemable_bets.json")["data"]["v3Bets"] +"""Real bets from the Polygon bets subgraph: two won singles waiting to be +claimed (467993, 467650), a won combo claimed (472005), a cancelled single +claimed (472056) and a 33-leg combo lost (472057).""" +BET = {b["tokenId"]: b for b in BETS} +CALLS = VECTORS["calls"] + + +def condition(condition_id: str, outcomes=("1", "2"), *, odds=("1.58", "2.3"), state="Active", forbidden=False, + hidden=False) -> dict: + """A condition as `conditions-by-game-ids` lists it.""" + return {"conditionId": condition_id, "state": state, "isExpressForbidden": forbidden, + "game": {"gameId": condition_id[4:26]}, + "outcomes": [{"outcomeId": o, "odds": p, "state": "Active", "hidden": hidden} for o, p in zip(outcomes, odds)]} + + +CONDITIONS = {CORNERS: condition(CORNERS), OTHER_GAME: condition(OTHER_GAME, odds=("1.51", "1.55")), + THREE_WAY: condition(THREE_WAY, ("1", "2", "3"), odds=("6.83", "4.95", "1.32"))} + + +class TestComboSigning: + @pytest.mark.parametrize("case", VECTORS["combos"], ids=lambda c: c["name"]) + def test_the_digest_and_signature_match_the_toolkit(self, case): + combo = sig.combo_bet(legs=[(b["conditionId"], b["outcomeId"]) for b in case["bets"]], + min_odds=int(case["minOdds"]), amount=int(case["amount"]), nonce=int(case["nonce"])) + assert "0x" + sig.combo_digest(case["clientData"], combo).hex() == case["digest"] + assert sig.sign_combo(WalletSigner(KEY), case["clientData"], combo) == case["signature"] + assert sig.combo_typed_data(case["clientData"], combo)["domain"] == case["domain"] + + @pytest.mark.parametrize("case", VECTORS["combos"], ids=lambda c: c["name"]) + def test_the_typed_data_is_what_a_wallet_signs(self, case): + combo = sig.combo_bet(legs=[(b["conditionId"], b["outcomeId"]) for b in case["bets"]], + min_odds=int(case["minOdds"]), amount=int(case["amount"]), nonce=int(case["nonce"])) + encoded = encode_typed_data(full_message=sig.combo_typed_data(case["clientData"], combo)) + assert "0x" + _hash_eip191_message(encoded).hex() == case["digest"] + assert EthAccount.recover_message(encoded, signature=case["signature"]) == OWNER.address + + def test_the_wire_body_is_create_combo_bets(self): + case = VECTORS["combos"][0] + combo = sig.combo_bet(legs=[(b["conditionId"], b["outcomeId"]) for b in case["bets"]], + min_odds=int(case["minOdds"]), amount=int(case["amount"]), nonce=int(case["nonce"])) + body = sig.wire_combo(POLYGON, bettor=OWNER.address, data=case["clientData"], combo=combo, + signature=case["signature"]) + assert body == { + "environment": "PolygonUSDT", "bettor": OWNER.address.lower(), "betOwner": OWNER.address.lower(), + "clientBetData": {"clientData": case["clientData"], "amount": case["amount"], "minOdds": case["minOdds"], + "nonce": case["nonce"], + "bets": [{"conditionId": b["conditionId"], "outcomeId": int(b["outcomeId"])} + for b in case["bets"]]}, + "bettorSignature": case["signature"], + } + + +class TestComboOdds: + @pytest.mark.parametrize("bet", [b for b in BETS if b["type"] == "Express"], ids=lambda b: b["tokenId"]) + def test_the_combo_rule_gives_the_odds_the_protocol_recorded(self, bet): + assert sig.combo_odds([s["odds"] for s in bet["selections"]]) == Decimal(bet["odds"]) + + @pytest.mark.parametrize("case", [c for c in VECTORS["combo_odds"] if c["slippage"] == 0], ids=str) + def test_cut_to_two_decimals_it_is_the_toolkits_calc_min_odds(self, case): + odds = sig.combo_odds([str(o) for o in case["odds"]]) + assert odds.quantize(Decimal("0.01"), rounding="ROUND_DOWN") == Decimal(case["minOdds"]) + + def test_odds_that_take_no_bets(self): + for bad in ([], ["1"], ["1.5", "0.9"]): + with pytest.raises(InvalidOrder): + sig.combo_odds(bad) + + +class TestClaimCalldata: + def test_withdraw_payouts_is_the_toolkits_and_the_chains(self): + data = sig.withdraw_payouts_calldata(CALLS["core"], [472056, 472055, 472050, 472049, 472048]) + assert data == CALLS["withdrawPayouts"] + # The same five bets were claimed on Polygon by this exact input, + # transaction 0x034542780bd4527a8e118e6d469498201efb55f930c496a326540de0585fcd28. + assert data.startswith("0xe8e3e87b") + + def test_the_other_calls(self): + assert sig.withdraw_payout_calldata(CALLS["core"], 467993) == CALLS["withdrawPayout"] + assert sig.view_payout_calldata(CALLS["core"], "467993") == CALLS["viewPayout"] + assert sig.paymaster_withdraw_payouts_calldata([9001]) == CALLS["paymasterWithdrawPayouts"] + + def test_the_pools_are_the_toolkits(self): + for chain in CHAINS.values(): + assert chain.lp == CALLS["chains"][str(chain.chain_id)]["lp"] + + +class TestCombos: + LEGS = [ComboLeg(f"azuro:{CORNERS}"), ComboLeg(f"azuro:{OTHER_GAME}", Side.SELL)] + SELECTIONS = [(CORNERS, "1"), (OTHER_GAME, "2")] + + def test_a_combo_is_signed_with_a_worst_price_for_the_whole(self): + body, combo = build_combo(self.SELECTIONS, chain=POLYGON, signer=WalletSigner(KEY), quantity=Decimal("10"), + price=Decimal("0.4"), relayer_fee=12345, nonce=9, now=1_800_000_000) + assert combo == {"minOdds": str(25 * 10 ** 11), "amount": "4000000", "nonce": "9", + "bets": [{"conditionId": CORNERS, "outcomeId": "1"}, {"conditionId": OTHER_GAME, "outcomeId": "2"}]} + data = body["clientBetData"]["clientData"] + assert data["expiresAt"] == 1_800_000_300 and data["relayerFeeAmount"] == "12345" + assert body["clientBetData"]["bets"][1] == {"conditionId": OTHER_GAME, "outcomeId": 2} + encoded = encode_typed_data(full_message=sig.combo_typed_data(data, combo)) + assert EthAccount.recover_message(encoded, signature=body["bettorSignature"]) == OWNER.address + + def test_one_leg_is_no_combo(self): + with pytest.raises(InvalidOrder, match="at least 2"): + build_combo(self.SELECTIONS[:1], chain=POLYGON, signer=WalletSigner(KEY), quantity=Decimal("1"), + price=Decimal("0.5"), relayer_fee=1, nonce=1) + + def test_an_eligible_quote(self): + quote = combo_quote_of(self.LEGS, self.SELECTIONS, CONDITIONS, {"minBet": 1, "maxBet": 200, "maxPayout": "900"}, + currency="USDT", stake=Decimal("5")) + # 1.58 / 0.99 -> 1.60, 1.55 / 0.99 -> 1.57; 1.60 * 1.57 * 0.99 + assert quote.eligible and quote.odds == Decimal("2.48688") and quote.price == 1 / Decimal("2.48688") + assert (quote.min_bet, quote.max_bet, quote.max_payout) == (1, 200, 900) + assert [leg["odds"] for leg in quote.legs] == [Decimal("1.58"), Decimal("1.55")] + assert quote.legs[1]["market_id"] == f"azuro:{OTHER_GAME}" and quote.legs[1]["side"] == Side.SELL + + @pytest.mark.parametrize("change, problem", [ + (lambda c: {**c, CORNERS: condition(CORNERS, forbidden=True)}, "not offered in combos"), + (lambda c: {**c, CORNERS: condition(CORNERS, state="Stopped")}, "is Stopped"), + (lambda c: {**c, CORNERS: condition(CORNERS, hidden=True)}, "not taking bets"), + (lambda c: {k: v for k, v in c.items() if k != CORNERS}, "no condition"), + ]) + def test_what_stops_a_combo(self, change, problem): + quote = combo_quote_of(self.LEGS, self.SELECTIONS, change(CONDITIONS), {"maxBet": 10}, currency="USDT") + assert not quote.eligible and any(problem in p for p in quote.problems) + + def test_legs_on_one_game(self): + legs = [ComboLeg(f"azuro:{CORNERS}"), ComboLeg(f"azuro:{THREE_WAY}-3")] + quote = combo_quote_of(legs, [(CORNERS, "1"), (THREE_WAY, "3")], CONDITIONS, None, currency="USDT") + assert quote.problems == ["game 1006000000000094301994 is in the combo twice; legs must be on different games"] + twice = combo_quote_of(self.LEGS[:1] * 2, [(CORNERS, "1"), (CORNERS, "2")], CONDITIONS, None, currency="USDT") + assert any("in the combo twice" in p for p in twice.problems) + alone = combo_quote_of(self.LEGS[:1], self.SELECTIONS[:1], CONDITIONS, None, currency="USDT") + assert alone.problems == ["a combo needs at least 2 legs"] + + def test_the_stake_against_the_limits(self): + def problems(limits, stake): + return combo_quote_of(self.LEGS, self.SELECTIONS, CONDITIONS, limits, currency="USDT", + stake=Decimal(stake)).problems + assert problems({"maxBet": 0}, "1") == ["the pool takes nothing on this combo now"] + assert problems({"maxBet": 3}, "5") == ["a stake of 5 is above the pool's maximum of 3"] + assert problems({"minBet": 1, "maxBet": 3}, "0.5") == ["a stake of 0.5 is below the pool's minimum of 1"] + + +class TestComboRecords: + def combo(self, **changes): + legs = [row()["conditions"][0], {**row()["conditions"][0], "conditionId": OTHER_GAME, "outcomeId": 2, + "price": "1.55", "potentialAmount": None}] + return row(betType="COMBO", odds=2.48688, conditions=legs, **changes) + + def test_a_combo_is_a_buy_of_the_whole_with_its_legs(self): + order = order_of_row(self.combo(), outcomes=OUTCOMES[CORNERS], legs_outcomes=OUTCOMES) + assert order.info["combo"] and order.side == Side.BUY and order.market_id == f"azuro:{CORNERS}" + assert order.price == 1 / Decimal("2.48688") and order.quantity == Decimal("5") * Decimal("2.48688") + assert [(leg["market_id"], leg["side"]) for leg in order.info["legs"]] == [ + (f"azuro:{CORNERS}", Side.BUY), (f"azuro:{OTHER_GAME}", Side.SELL)] + + def test_a_settled_combo_and_its_claim(self): + won = settlement_of_row(self.combo(state="Settled", result="Won", payout=12.4344), outcomes=OUTCOMES[CORNERS], + legs_outcomes=OUTCOMES) + assert won.info["combo"] and won.won and won.payout == Decimal("12.4344") and won.pnl == Decimal("7.4344") + assert won.info["claimed"] is False and won.info["claimable"] == Decimal("12.4344") + claimed = settlement_of_row(self.combo(state="Settled", result="Won", payout=12.4344, + redeemedAt="2026-10-24T00:00:00.000Z", + meta={"isRedeemed": True, "redeemedTxHash": "0x" + "cd" * 32}), + outcomes=OUTCOMES[CORNERS]) + assert claimed.info["claimed"] is True and claimed.info["claimable"] == 0 + assert claimed.info["claim_transaction"] == "0x" + "cd" * 32 + lost = settlement_of_row(self.combo(state="Settled", result="Lost"), outcomes=OUTCOMES[CORNERS]) + assert lost.won is False and lost.info["claimed"] is None and lost.info["claimable"] == 0 + + def test_a_cancelled_single_returns_its_stake_to_claim(self): + void = settlement_of_row(row(state="Settled", result="Canceled"), outcomes=OUTCOMES[CORNERS]) + assert void.payout == Decimal("5") and void.info["claimable"] == Decimal("5") + cashed = settlement_of_row(row(state="Settled", result="Won", payout=10.2, meta={"isCashedOut": True}), + outcomes=OUTCOMES[CORNERS]) + assert cashed.info["claimable"] == 0 + + +class TestRedeemable: + def test_won_bets_waiting_are_claims(self): + claim = redeemable_of_bet(BET["467993"]) + assert claim["bet_id"] == "467993" and claim["result"] == "won" and not claim["combo"] + assert (claim["amount"], claim["raw_amount"], claim["stake"]) == (Decimal("5.84"), 5_840_000, Decimal("2")) + assert (claim["core"], claim["lp"]) == (POLYGON.core.lower(), POLYGON.lp.lower()) + assert claim["legs"] == [("600610060000000000901243060000000000000131378013", "3")] + + def test_claimed_and_lost_bets_are_not(self): + assert [redeemable_of_bet(BET[i]) for i in ("472005", "472056", "472057")] == [None, None, None] + + def test_a_cancelled_bet_returns_its_stake(self): + waiting = {**BET["472056"], "isRedeemable": True, "isRedeemed": False, "rawPayout": None} + claim = redeemable_of_bet(waiting) + assert claim["result"] == "canceled" and claim["amount"] == Decimal("10") + + def test_a_won_combo(self): + waiting = {**BET["472005"], "isRedeemable": True, "isRedeemed": False} + claim = redeemable_of_bet(waiting) + assert claim["combo"] and claim["amount"] == Decimal("17.48736") and len(claim["legs"]) == 2 + + def test_a_free_bets_share(self): + assert freebet_share(30, 10, None) == 20 and freebet_share(30, 10, False) == 30 + assert freebet_share(10, 10, True) == 0 + free = {**BET["467993"], "freebetId": "77", "paymasterContractAddress": CALLS["paymaster"], + "isFreebetAmountReturnable": True} + assert redeemable_of_bet(free)["amount"] == Decimal("3.84") + + def test_claims_are_batched_per_pool(self): + claims = [redeemable_of_bet(BET["467993"]), redeemable_of_bet(BET["467650"])] + [call] = claim_calls(claims, chain=POLYGON) + assert call["to"] == POLYGON.lp and call["bet_ids"] == ["467993", "467650"] + assert call["data"] == sig.withdraw_payouts_calldata(POLYGON.core, [467993, 467650]) + assert call["raw_amount"] == 5_840_000 + 1_530_000 + assert [c["bet_ids"] for c in claim_calls(claims, chain=POLYGON, batch=1)] == [["467993"], ["467650"]] + + def test_free_bets_and_legacy_pools_claim_one_each(self): + free = redeemable_of_bet({**BET["467993"], "freebetId": "9001", "paymasterContractAddress": CALLS["paymaster"]}) + legacy = {**redeemable_of_bet(BET["467650"]), "lp": "0x" + "12" * 20} + paid, old = claim_calls([free, legacy], chain=POLYGON) + assert (paid["to"], paid["data"]) == (CALLS["paymaster"], CALLS["paymasterWithdrawPayouts"]) + assert old["to"] == "0x" + "12" * 20 and old["data"] == sig.withdraw_payout_calldata(POLYGON.core, 467650) + with pytest.raises(InvalidOrder, match="paymaster"): + claim_calls([{**free, "paymaster": None}], chain=POLYGON) + + def test_claimable_positions(self): + claims = [redeemable_of_bet(BET["467993"])] + three_way = "600610060000000000901243060000000000000131378013" + [position] = claimable_positions_of(claims, outcomes={three_way: ["1", "2", "3"]}, decimals=6) + assert position.market_id == f"azuro:{three_way}-3" and position.resolved and position.won + assert position.redeemable == position.payout == Decimal("5.84") and position.contracts == Decimal("5.84") + assert position.margin == Decimal("2") and position.info["bet_ids"] == ["467993"] + + +def rpc(result): + return {"jsonrpc": "2.0", "id": 1, "result": result} + + +def sent_transactions(venue: Venue) -> list[dict]: + raws = [json.loads(r.content)["params"][0] for r in venue.requests + if r.url.host == "rpc.test" and json.loads(r.content)["method"] == "eth_sendRawTransaction"] + return [TypedTransaction.from_bytes(HexBytes(raw)).as_dict() for raw in raws] + + +class TestComboAdapter: + LEGS = [f"azuro:{CORNERS}", (f"azuro:{OTHER_GAME}", "sell")] + CALC = "POST /api/v1/public/bet/calculation" + COMBO = "POST /api/v1/public/bet/orders/combo" + + def venue(self, **more): + return Venue(**{GAS: {"relayerFeeAmount": "20000"}, self.CALC: {"response": {"minBet": 1, "maxBet": 100, + "maxPayout": "1000"}}, + self.COMBO: {"id": "0xf39f_2", "state": "Created"}, **more}) + + def test_a_combo_is_quoted(self): + venue = self.venue() + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + return await azuro.fetch_combo_quote(self.LEGS, stake=Decimal("4")) + + quote = run(go()) + assert quote.eligible and quote.odds == Decimal("2.48688") and quote.currency == "USDT" + asked = json.loads(venue.sent(self.CALC)[0].content) + assert asked == {"bets": [{"conditionId": CORNERS, "outcomeId": 1}, {"conditionId": OTHER_GAME, "outcomeId": 2}], + "environment": "PolygonUSDT", "wallet": OWNER.address} + + def test_a_combo_is_signed_and_handed_to_the_relayer(self): + venue = self.venue() + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + return await azuro.create_combo_order(self.LEGS, quantity=Decimal("10"), price=Decimal("0.42")) + + order = run(go()) + body = json.loads(venue.sent(self.COMBO)[0].content) + bet = body["clientBetData"] + assert bet["amount"] == "4200000" and bet["minOdds"] == str(sig.min_odds(Decimal("0.42"))) + assert bet["bets"] == [{"conditionId": CORNERS, "outcomeId": 1}, {"conditionId": OTHER_GAME, "outcomeId": 2}] + assert bet["clientData"]["relayerFeeAmount"] == "20000" + combo = sig.combo_bet(legs=[(CORNERS, "1"), (OTHER_GAME, "2")], min_odds=int(bet["minOdds"]), + amount=int(bet["amount"]), nonce=int(bet["nonce"])) + encoded = encode_typed_data(full_message=sig.combo_typed_data(bet["clientData"], combo)) + assert EthAccount.recover_message(encoded, signature=body["bettorSignature"]) == OWNER.address + assert (order.id, order.status, order.side, order.cost) == ("0xf39f_2", OrderStatus.PENDING, Side.BUY, Decimal("4.2")) + assert order.info["combo"] and [leg["side"] for leg in order.info["legs"]] == [Side.BUY, Side.SELL] + assert order.quantity == Decimal("10") + + def test_a_combo_that_would_be_refused_is_never_signed(self): + venue = self.venue() + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + await azuro.create_combo_order([f"azuro:{CORNERS}", f"azuro:{THREE_WAY}-3"], quantity=Decimal("10"), + price=Decimal("0.1")) + + with pytest.raises(InvalidOrder, match="different games"): + run(go()) + assert not venue.sent(self.COMBO) + + def test_a_refused_combo_is_typed(self): + venue = self.venue(**{self.COMBO: httpx.Response(400, json={"errorMessage": "Insufficient balance"})}) + + async def go(): + async with adapter(venue, catalog=Catalog(CONDITIONS)) as azuro: + await azuro.create_combo_order(self.LEGS, quantity=Decimal("10"), price=Decimal("0.42"), check=False) + + with pytest.raises(InsufficientFunds): + run(go()) + assert not venue.sent(self.CALC), "check=False reads no quote" + + +class TestClaimAdapter: + def venue(self, bets, **rpcs): + answers = { + SUBGRAPH: {"data": {"v3Bets": bets}}, + "RPC eth_estimateGas": rpc(hex(150_000)), "RPC eth_getTransactionCount": rpc("0x7"), + "RPC eth_gasPrice": rpc(hex(30 * 10 ** 9)), "RPC eth_maxPriorityFeePerGas": rpc(hex(10 ** 9)), + "RPC eth_sendRawTransaction": lambda req: rpc("0x" + "%064x" % len(req.content)), + "RPC eth_getTransactionReceipt": rpc({"status": "0x1"}), + } + return Venue(**{**answers, **{f"RPC {k}": v for k, v in rpcs.items()}}) + + def test_the_redeemable_bets_are_read_from_the_subgraph(self): + venue = self.venue(BETS) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.fetch_redeemable_bets() + + bets = run(go()) + assert [b["bet_id"] for b in bets] == ["467993", "467650"] + asked = json.loads(venue.sent(SUBGRAPH)[0].content)["variables"] + assert asked["where"] == {"actor": OWNER.address.lower(), "isRedeemable": True, "isCashedOut": False} + assert asked["skip"] == 0 + + def test_claims_go_in_one_transaction_from_the_wallet(self): + venue = self.venue(BETS) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.claim_winnings() + + result = run(go()) + assert result["bet_ids"] == ["467993", "467650"] and result["amount"] == Decimal("7.37") + [entry] = result["transactions"] + assert entry["to"] == POLYGON.lp and entry["receipt"] == {"status": "0x1"} + [estimate] = [json.loads(r.content)["params"][0] for r in venue.requests + if r.url.host == "rpc.test" and json.loads(r.content)["method"] == "eth_estimateGas"] + assert estimate == {"from": OWNER.address, "to": POLYGON.lp, + "data": sig.withdraw_payouts_calldata(POLYGON.core, [467993, 467650])} + [tx] = sent_transactions(venue) + assert tx["nonce"] == 7 and tx["to"] == HexBytes(POLYGON.lp) and tx["chainId"] == 137 + assert "0x" + bytes(tx["data"]).hex() == estimate["data"] and tx["gas"] == 180_000 + + def test_one_bet_and_batches(self): + venue = self.venue(BETS) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.redeem(["467650"], wait=False), await azuro.redeem(batch=1, wait=False) + + one, both = run(go()) + assert one["bet_ids"] == ["467650"] and "receipt" not in one["transactions"][0] + assert [t["bet_ids"] for t in both["transactions"]] == [["467993"], ["467650"]] + assert [t["nonce"] for t in sent_transactions(venue)] == [7, 7, 8] + + def test_a_bet_with_nothing_to_claim(self): + async def go(): + async with adapter(self.venue(BETS)) as azuro: + await azuro.redeem(["472005"]) + + with pytest.raises(InvalidOrder, match="nothing to claim on bet 472005"): + run(go()) + + def test_nothing_to_claim_sends_nothing(self): + venue = self.venue([]) + + async def go(): + async with adapter(venue) as azuro: + return await azuro.redeem() + + assert run(go()) == {"transactions": [], "bet_ids": [], "amount": 0, "currency": "USDT"} + assert not [r for r in venue.requests if r.url.host == "rpc.test"] + + def test_a_claim_that_would_revert_is_refused_before_sending(self): + paid = {"jsonrpc": "2.0", "id": 1, "error": {"code": 3, "message": "execution reverted", "data": "0xd70a0e30"}} + venue = self.venue(BETS, eth_estimateGas=paid) + + async def go(): + async with adapter(venue) as azuro: + await azuro.redeem() + + with pytest.raises(OrderRejected, match="AlreadyPaid") as caught: + run(go()) + assert caught.value.reason == "alreadypaid" and not sent_transactions(venue) + + def test_a_reverted_claim(self): + venue = self.venue(BETS, eth_getTransactionReceipt=rpc({"status": "0x0"})) + + async def go(): + async with adapter(venue) as azuro: + await azuro.redeem() + + with pytest.raises(OrderRejected, match="reverted"): + run(go()) + + def test_what_a_claim_pays_now_is_read_on_chain(self): + def call(req): + target = json.loads(req.content)["params"][0] + assert target == {"to": POLYGON.lp, "data": sig.view_payout_calldata(POLYGON.core, 467993)} + return rpc(hex(5_840_000)) + + async def go(): + async with adapter(self.venue([], eth_call=call)) as azuro: + return await azuro.fetch_claimable(467993) + + assert run(go()) == Decimal("5.84") + + def test_positions_carry_what_waits_to_be_claimed(self): + three_way = "600610060000000000901243060000000000000131378013" + venue = self.venue(BETS[:1]) + venue.answers[BETTOR] = {"orders": [row()]} + + async def go(): + async with adapter(venue, catalog=Catalog(outcomes={three_way: ["1", "2", "3"]})) as azuro: + return await azuro.fetch_positions() + + open_position, waiting = run(go()) + assert open_position.market_id == f"azuro:{CORNERS}" and not open_position.resolved + assert waiting.market_id == f"azuro:{three_way}-3" and waiting.resolved and waiting.redeemable == Decimal("5.84") + + class TestCredentials: def test_a_key_alone_bets_on_polygon(self): creds = load_azuro({"AZURO_PRIVATE_KEY": KEY[2:]}) From 9f0e779ef25be60e9313bc607cf422bdab17c13b Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 15:37:45 +0000 Subject: [PATCH 7/8] fix: combine venue follow-ups README: Novig's parlay RFQ is now supported for liquidity providers. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d7529e..614bd03 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Gemini Predictions is Gemini Titan's CFTC-registered event-contract exchange. Ea Smarkets, a UK betting exchange, is read the same way: each contract is a yes/no market, `smarkets:164973231-456060183`, and the Smarkets market holding the contracts is the event. Its market data needs no account (quotes are delayed without a login); betting logs in with your own Smarkets username and password, and placing orders needs Smarkets' approved API access (a one-off £150 fee). Smarkets' API terms allow viewing prices and betting on your own account, and prohibit odds scraping. See [capabilities](docs/capabilities.md#smarkets). -Novig, a US sports exchange, lists two-outcome markets (a moneyline, a spread, a total, a Yes/No prop), each one yes/no market, `novig:01a1577c-d666-7e81-852e-68bed1446801`, under its game as the event. Its catalog, books and trades are public and need no key. Trading signs each request with a subaccount's key; Production trades real money for verified US customers outside Arizona, Michigan and Nevada, and Paper (`NOVIG_ENV=paper`) is the same API with play money. Novig's parlay RFQ, for registered liquidity providers, is not supported yet. See [capabilities](docs/capabilities.md#novig). +Novig, a US sports exchange, lists two-outcome markets (a moneyline, a spread, a total, a Yes/No prop), each one yes/no market, `novig:01a1577c-d666-7e81-852e-68bed1446801`, under its game as the event. Its catalog, books and trades are public and need no key. Trading signs each request with a subaccount's key; Production trades real money for verified US customers outside Arizona, Michigan and Nevada, and Paper (`NOVIG_ENV=paper`) is the same API with play money. Novig's parlay RFQ is supported from the liquidity provider's side: `NovigPricer` prices and last-looks parlays for a registered LP account ([trading](docs/trading.md#novig-parlay-rfq)). See [capabilities](docs/capabilities.md#novig). Matchbook, a betting exchange licensed in the UK and Alderney, is read the same way: each runner is a yes/no market, `matchbook:--`, and the Matchbook market holding the runners is the event. Its market data needs no account; betting logs in with your own Matchbook username and password. Every GET is chargeable on Matchbook's API plans (£100 per million a month), and its terms allow viewing prices and betting on your own account only. See [capabilities](docs/capabilities.md#matchbook). From 6b55c9e33a8c8fdcf241d25c701c20e9d170845a Mon Sep 17 00:00:00 2001 From: Yuki Chu Date: Sun, 25 Oct 2026 15:39:05 +0000 Subject: [PATCH 8/8] chore: prepare synpath 0.11.0 release Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- synpath/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e441b5d..8cc5da4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ backend-path = ["build_backend"] [project] name = "synpath" -version = "0.10.0" +version = "0.11.0" description = "One API for 16 prediction markets and sports exchanges: Kalshi, Polymarket, Polymarket US, Opinion, Hyperliquid, predict.fun, Limitless, SX Bet, Myriad, Gemini, Betfair, Smarkets, Novig, Matchbook, ProphetX and Azuro. Market data, streaming, self-hosted trading and smart order routing." readme = "README.md" requires-python = ">=3.10" diff --git a/synpath/__init__.py b/synpath/__init__.py index 50c85ad..24daa26 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -88,7 +88,7 @@ TradesRangeResponse, ) -__version__ = "0.10.0" +__version__ = "0.11.0" exchanges: dict[str, type[Exchange]] = { "kalshi": Kalshi,